From ec6f13ddcc65e96b04d2014bc6143d829a3f92ad Mon Sep 17 00:00:00 2001 From: kosta2014p Date: Sun, 18 Oct 2026 22:44:19 +0700 Subject: [PATCH] =?UTF-8?q?PixelLauncher=202.1.0=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=81?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров (Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала. --- .gitignore | 27 + README.md | 215 ++ assets/icon.png | Bin 0 -> 1303 bytes assets/make_logo.py | 276 +++ installer.iss | 74 + launcher/__init__.py | 4 + launcher/config.py | 153 ++ launcher/crash.py | 90 + launcher/downloader.py | 225 ++ launcher/forge.py | 199 ++ launcher/http.py | 314 +++ launcher/i18n.py | 483 ++++ launcher/java.py | 175 ++ launcher/java_fetch.py | 198 ++ launcher/launch.py | 99 + launcher/manifest.py | 111 + launcher/modrinth.py | 176 ++ launcher/mods.py | 198 ++ launcher/mrpack.py | 145 ++ launcher/onboarding.py | 305 +++ launcher/plugins.py | 390 +++ launcher/protocol.py | 173 ++ launcher/selfupdate.py | 130 + launcher/stats.py | 132 + launcher/tray.py | 252 ++ launcher/ui.py | 5269 ++++++++++++++++++++++++++++++++++++++++ launcher/version.py | 265 ++ launcher/worlds.py | 145 ++ main.py | 51 + make_installer.py | 159 ++ plugins/.gitkeep | 2 + requirements.txt | 3 + run.bat | 7 + 33 files changed, 10445 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/icon.png create mode 100644 assets/make_logo.py create mode 100644 installer.iss create mode 100644 launcher/__init__.py create mode 100644 launcher/config.py create mode 100644 launcher/crash.py create mode 100644 launcher/downloader.py create mode 100644 launcher/forge.py create mode 100644 launcher/http.py create mode 100644 launcher/i18n.py create mode 100644 launcher/java.py create mode 100644 launcher/java_fetch.py create mode 100644 launcher/launch.py create mode 100644 launcher/manifest.py create mode 100644 launcher/modrinth.py create mode 100644 launcher/mods.py create mode 100644 launcher/mrpack.py create mode 100644 launcher/onboarding.py create mode 100644 launcher/plugins.py create mode 100644 launcher/protocol.py create mode 100644 launcher/selfupdate.py create mode 100644 launcher/stats.py create mode 100644 launcher/tray.py create mode 100644 launcher/ui.py create mode 100644 launcher/version.py create mode 100644 launcher/worlds.py create mode 100644 main.py create mode 100644 make_installer.py create mode 100644 plugins/.gitkeep create mode 100644 requirements.txt create mode 100644 run.bat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c447bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Личные настройки (могут содержать пароль прокси) — не коммитим +settings.json + +# Кэш Python +__pycache__/ +*.pyc +*.pyo + +# Игровая папка разработки и рантайм-данные +.minecraft/ +plugins/* +!plugins/.gitkeep +launcher/logs/ + +# Сборочные артефакты и бинарники +build/ +dist/ +*.exe +!assets/*.exe +*.zip +*.7z + +# Логи и мусор +*.log +.proto/ +Thumbs.db +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..157ce22 --- /dev/null +++ b/README.md @@ -0,0 +1,215 @@ +# PixelLauncher + +Компактный оффлайн-лаунчер Minecraft в стиле **LiteLauncher**: тёмное минималистичное окно, пиксельный пейзаж-баннер, большая зелёная кнопка «ИГРАТЬ» и пилюля с версией. + +## Возможности + +- **Оффлайн-режим** — ник + сгенерированный по правилам Mojang UUID +- **Менеджер аккаунтов** — несколько ников, клик по нику в шапке для быстрого + переключения, управление в «Аккаунты…» +- **Все версии** из официального манифеста Mojang (920+): релизы, снапшоты, старые версии +- **Лента новостей** — последние версии прямо на главном экране (клик выбирает версию) +- **Fabric и Quilt** — установка модлоадера одной кнопкой + (меню → «Установить Fabric/Quilt…»): скачивается JSON профиля, файлы + лоадера докачиваются при первом запуске +- **Автодокачка** client jar, библиотек, нативов и ассетов с проверкой SHA-1 (многопоточно) +- **Отмена загрузки** — кнопка «■ ОТМЕНА» под статусом во время докачки +- **Зеркала скачивания** — если официальные серверы Mojang плохо грузятся, + укажите зеркала в настройках (по одному в строке) +- **Автозагрузка Java** — если подходящей Java нет, лаунчер сам скачает JRE + с Eclipse Adoptium (~45 МБ) в `.launcher/runtime/` и запустит игру +- **Детектор вылетов** — при ненулевом коде завершения журнал открывается сам + и показывает хвост лога с ошибкой +- **Живой лог игры** — вывод игры течёт в окно «Журнал» в реальном времени +- **Очистка версий** — меню → «Очистка версий…»: размер каждой установленной + версии, удаление ненужных и подчистка неиспользуемых ассетов +- **Моды** — меню → «Моды…»: список установленных модов с включением/выключением + (переименование в `mods-disabled`), удалением и поиском в **Modrinth** + (нужен только стандартный HTTP) — установка одной кнопкой под текущую + версию и загрузчик +- **Импорт модпаков** `.mrpack` (CurseForge/Modrinth-пакеты): скачивание файлов + по индексу, раскладка оверрайдов, проверка путей +- **Обновления модов** — в «Моды…» кнопка «Проверить обновления»: включённые + моды сверяются с Modrinth по SHA-1 (батчи через `POST /version_files`), + свежие версии скачиваются, старый jar отключается в `.disabled` +- **Разбор вылетов** — после аварийного завершения журнал сам называет причину: + нехватка ОЗУ (с подсказкой `-Xmx`), проблема с Java, исключение (корневое + `Caused by`) и виновный мод — с вопросом «Отключить его?» +- **Профили запуска** — меню → «Профили…»: несколько профилей (ник, версия, + ОЗУ, полный экран и по желанию набор включённых модов), переключение + одной кнопкой, имя активного профиля в пилюле +- **Миры и копии** — меню → «Миры и копии…»: список миров с размером и датой, + ZIP-копия по одному миру с капом последних копий (настраивается), + удаление копий и миров, быстрый переход в папку копий +- **Избранные версии** — ★ в диалоге версий закрепляет версию сверху, + фильтр «только избранные», частые (по статистике запусков) поднимаются + вверх списка +- **Трей** — свёртывание в трей (иконка в области уведомлений), плашки о + статусе запуска/вылета, контекстное меню с командами лаунчера +- **История и статистика** — меню → «История…»: сколько раз запускалась каждая + версия, суммарное и последнее время игры, сводка вылетов +- **Прокси** — настройки → сеть: HTTP и SOCKS5 (с авторизацией), проверка + соединения кнопкой +- **Настройки запуска игры** — профиль: пресеты JVM (мало RAM / быстро / + безопасно / чисто / для Fabric), разрешение и полный экран, окно игры +- **Архив логов** — окно «Журнал» показывает сеанс и все прошлые + `game-*.log` с поиском по тексту (Ctrl+F, «только ошибки») +- **Скин** — предпросмотр фронтального вида по нику (кэш mc-heads.net) или + своё фото (PNG 64×64), кнопка рядом с ником +- **Сцены баннера** — день / ночь / незер / пещера или случайная при каждом + открытии (меню → «Сцена баннера») +- **Масштаб интерфейса** 100/125/150% (настройки), ярлык текущей версии на + рабочем столе, экспорт/импорт настроек в JSON +- **Быстрые повторные запуски** — см. раздел «Скорость» +- **Проверка целостности** — меню → «Проверить файлы игры…» (полный пересчёт SHA-1) +- **Наследование версий** (`inheritsFrom`), виртуальные ассеты для старых версий +- **Автоподбор Java** под конкретную версию игры (PATH, JAVA_HOME, Program Files, ~/.jdks, /usr/lib/jvm) +- **Профиль**: ник, ОЗУ (или «авто» по памяти системы), Java, JVM-аргументы +- **Резервная копия миров** в ZIP +- **Выбор версии** в отдельном диалоге: поиск-фильтр, даты релиза, отметка + снапшотов и локальных (Fabric/Quilt) версий +- **Язык интерфейса RU/EN** — переключается в настройках +- **Акцентный цвет** — пять вариантов в настройках +- **Окно можно менять по размеру** — захват за «◢» в правом нижнем углу +- **Быстрый доступ к папкам** — игры, миров и скриншотов прямо из меню +- **Журнал** с цветными сообщениями (успех/ошибка/предупреждение) и Ctrl+C для копирования +- **Лог запуска игры** — полный вывод пишется в `.launcher/logs/game-*.log` +- **Автообновление лаунчера** — меню → «Проверить обновление лаунчера…» и + тихая проверка при старте: манифест `/update.json`, скачивание установщика + с проверкой SHA-256, запуск обновления в один клик +- **Своё зеркало API** — запросы к Modrinth и манифесту Mojang идут через + прокси на VPS; при сбое зеркало отключается до конца сессии и запрос уходит + напрямую, на официальный путь +- Без сторонних библиотек — только tkinter + +## Запуск + +``` +python main.py +``` + +Или двойной клик по `run.bat` (Windows). + +Требуется Python 3.8+ с tkinter. Дополнительные библиотеки ставить **не нужно**. + +### Сборка установщика + +``` +python make_installer.py +``` + +Собирает `PixelLauncherSetup<версия>.exe` прямо в папке проекта. Внутри — +embeddable Python той же версии + заимствованный tkinter/Tcl, приложение и +иконка; установка в `%LOCALAPPDATA%\PixelLauncher` без прав администратора +(скрипт `installer.iss`, нужен Inno Setup 6 с `ISCC.exe`). Версия в +`installer.iss` должна совпадать с `LAUNCHER_VERSION`; после новой сборки +обновите файл и `update.json` на сервере. + +## Плагины и протокол + +- **Плагины** — файлы Python в папке `plugins` рядом с `main.py`: + константы `PLUGIN_NAME/VERSION/AUTHOR/DESCRIPTION`, функция `on_load(api)` + (подписки `api.on(...)`, кнопки `api.add_action(...)`) и необязательный + `on_unload()`. События: `launcher_ready`, `before_launch`, + `after_launch`, `game_exit`, `profile_switched`, `account_switched`, + `launcher_closing`. Своё json-хранилище плагина — `plugins/storage/`. + Управление: меню ⚙ → «Плагины…» (пересканирование, удаление) и + «Магазин плагинов…» (каталог с сайта). +- **Протокол `pixellauncher://`** — ссылки с сайта: `plugin/<файл.py>` + ставит плагин из магазина, `mrpack/` скачивает и импортирует + модпак. Ключи HKCU пишет установщик; если лаунчер уже открыт, ссылка + уходит в очередь и обрабатывается работающей копией. +- Формат плагинов совпадает с верстаком на сайте (`/constructor`); + каталог магазина — `/plugins/catalog.json`. + +## Скорость + +Повторные запуски не тратят время на лишнюю работу: + +- **Манифест версий** (около 1.5 МБ) не перекачивается каждый раз: свежий кэш + (до 1 часа) читается с диска — загрузка списка занимает ~0.1 с вместо ~2 с. + «Обновить список версий» в диалоге принудительно ходит в сеть. +- **JSON версии** кэшируется на 6 часов и не качается заново при каждом запуске. +- **Проверка уже скачанных файлов** — по размеру (один `stat` на файл) вместо + полного чтения и пересчёта SHA-1. Для версии с 3000+ ассетов это секунды + на каждом старте. Полная проверка SHA-1 — по запросу: + меню → «Проверить файлы игры…». +- **Обнаружение Java** кэшируется (`.launcher/java_cache.json`): `java -version` + прогоняется по кандидатам один раз, повторные запуски берут версию из кэша. +- **Нативы** распаковываются один раз: повторная распаковка пропускается, пока + natives-jar'ы не изменились (сигнатура в `.stamp`). +- **Загрузка файлов** — до 32 параллельных потоков, прогресс обновляется по + мере готовности каждого файла. + +## Интерфейс + +- **Верхняя панель**: логотип, ник (клик — переключение аккаунтов), + шестерёнка — меню (профиль, профили запуска, аккаунты, версии, Fabric/Quilt, + копия, миры и копии, плагины, магазин плагинов, скачивание, очистка, папки, + журнал, настройки, о программе) +- **Баннер**: пиксельный пейзаж с заголовком PIXEL LAUNCHER, тянется на всю + ширину окна; сцена переключается в меню (день/ночь/незер/пещера/random) +- **ИГРАТЬ** — большая зелёная кнопка с анимацией нажатия + (цвет = акцентный цвет из настроек) +- **Пилюля** `Minecraft <версия>` — клик открывает список версий +- **Лента «Последние версии»** — 3 свежие версии из манифеста, клик выбирает версию +- Внизу — тонкая полоса прогресса и строка статуса; во время загрузки под + статусом появляется кнопка отмены +- Кнопка «—» сворачивает лаунчер в трей (кнопка «×» закрывает) +- Диалоги со списком версий идут в тёмном оформлении (Windows 10/11) + +Все файлы игры хранятся в `.minecraft` рядом с лаунчером (меню → Настройки). + +## Структура + +``` +main.py точка входа +launcher/ + ui.py интерфейс: окно, баннер, диалоги + config.py настройки (settings.json), аккаунты, зеркала + i18n.py перевод интерфейса (RU/EN) + manifest.py манифест версий Mojang + кэш, локальные версии + version.py разбор JSON версии, classpath, аргументы + downloader.py докачка библиотек/ассетов, зеркала, отмена, нативы + java.py поиск и выбор Java + java_fetch.py автозагрузка JRE с Eclipse Adoptium + mods.py установщик Fabric/Quilt + менеджер модов, обновления + modrinth.py поиск и установка модов с Modrinth API (+ POST /version_files) + mrpack.py импорт модпаков .mrpack + crash.py разбор логов вылетов: причина и виновный мод + worlds.py список миров, ZIP-копии с капом, удаление + stats.py история запусков и статистика игры + tray.py иконка в трей, меню и плашки (Windows) + launch.py оффлайн-UUID, запуск, живой лог, код завершения + http.py HTTP-хелперы: повторы, SHA-1, прогресс, отмена, прокси, + зеркала API (одна попытка через зеркало, затем напрямую) + selfupdate.py автообновление: манифест /update.json, SHA-256, установщик + onboarding.py мастер первого запуска: язык, ник, ОЗУ + plugins.py плагины: загрузка .py, события, API, хранилище, магазин + protocol.py протокол pixellauncher:// и одна живая копия лаунчера +``` + +## Примечания + +- Работает только оффлайн-режим Minecraft (серверы в offline-режиме). +- Java: 1.20.5+ → Java 21+, 1.16–1.20 → Java 17+, 1.12–1.16 → Java 8/11. + Лаунчер подберёт подходящую автоматически, а если Java не найдена — + скачает её с Eclipse Adoptium и положит в `.launcher/runtime/`. +- Лента новостей собирается из манифеста версий Mojang + (официальные RSS-эндпоинты minecraft.net закрыты). +- Зеркала скачивания задаются в настройках (по одному URL в строке); + SHA-1 у файлов одинаков на любых зеркалах, проверка не меняется. +- Прокси (HTTP/SOCKS5) применяется ко всем запросам лаунчера, включая + загрузку файлов игры, поиск модов и установку модпаков. +- Манифест автообновления и зеркала API живут на VPS и доступны по домену: + база обновлений — `https://pixellauncher.ru` (`UPDATE_BASE` в + `selfupdate.py`), зеркала — `https://pixellauncher.ru/proxy/…`. HTTPS + выпущен Let's Encrypt (certbot), автопродление — snap-таймер certbot. +- Установленный лаунчер не пишет в системные папки: файлы игры, `settings.json` + и сам лаунчер лежат в `%LOCALAPPDATA%\PixelLauncher`, деинсталлятор не + трогает `.minecraft` и журналы. +- При первом запуске показывается мастер первоначальной настройки (язык, + ник, ОЗУ) — `launcher/onboarding.py`. Он закрывается кнопкой + «Пропустить настройку», после чего не появляется снова: флаг + `onboarding_done` в `settings.json` (апгрейд со старой версии мастер + не показывает). diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5832905a7304d68be9bb6ef6692f8720a4cb5b79 GIT binary patch literal 1303 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJEid!8tRTVxj=w9)E^Yj~1~pE=X3uqrm|r2XqXB!(OHdzwBRuxI_U1ErxmRZoGdPit6i$ z(mkCoA2uY}}7h({TYoK6Q?>;QC$3W5R|2~KVd*zy2a(34>T3j-wtCj-L-pf%{! zee1aGQ|HS6UU4^W`KdRX^UwT#^-h@K+f8-`h8yYf3=9eLYk~IHeglRjs^E_^>?!kW zU%q3;CW)#ezK-FFZNrB1`yRf_u6e+);k+FK!vVYBK-c_!!_2_&W-~Coq6#+1^G&?; z^C3TaaR3XS3`vd#kN@%oUzph&fB}bDc1)3F*vL3BNbkwNA3_}1eV%KdFCPBONVq&s Q0q7D2Pgg&ebxsLQ0Dkkvng9R* literal 0 HcmV?d00001 diff --git a/assets/make_logo.py b/assets/make_logo.py new file mode 100644 index 0000000..7b8960e --- /dev/null +++ b/assets/make_logo.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +"""Генератор логотипа PixelLauncher — пиксель-арт на чистом stdlib. + +Рисует логотип на логическом поле 32x32 и масштабирует x8 -> 256x256 PNG. + +Запуск: + python assets/make_logo.py # варианты land/gold/face -> превью + python assets/make_logo.py land out.png # конкретный вариант в файл + +Варианты: + land — пиксельная «P», сверху трава, снизу земля (буква-как-блок); + gold — золотая «P» на тёмном фоне с травяной полосой; + face — зелёный блок с тёмной «P» (родственник крипера). +""" +import os +import struct +import sys +import zlib + +S = 32 # логическое поле +K = 8 # масштаб -> 256 + +# палитра сайта/лаунчера +DARK = (27, 31, 43) # #1b1f2b фон +FRAME = (14, 16, 22) # тёмная рамка +LINE = (51, 58, 77) # #333a4d +GOLD = (255, 180, 84) # #ffb454 +GOLD_D = (217, 171, 52) # #d9ab34 +GOLD_L = (255, 217, 138) +GRASS = (124, 179, 66) # rgb(124,179,66) +GRASS_D = (90, 143, 50) +GRASS_L = (148, 201, 95) +DIRT = (125, 88, 54) # #7d5836 +DIRT_D = (107, 74, 47) # #6b4a2f +INK = (12, 13, 18) + +# пиксельная «P»: 6 шириной, 8 высотой? — см. LETTER (x: 6 колонок, 8 строк) +LETTER = [ + "XXXXXX", + "X..XX.", # снизу-вверх перевернём: рисуем снизу вверх? см. ниже +] + +# настоящая «P» 6x8 (X = заливка) +LETTER = [ + "XXXXXX", + "X..XX.", + "X..XX.", + "XXXXXX", + "XX....", + "XX....", + "XX....", + "XX....", +] + +# большая «P» 10x16 для 32x32 поля +LETTER_BIG = [ + "XXXXXXXXXX", + "XX......XX", + "XX......XX", + "XX......XX", + "XX......XX", + "XX......XX", + "XXXXXXXXXX", + "XX........", + "XX........", + "XX........", + "XX........", + "XX........", + "XX........", + "XX........", + "XX........", + "XX........", +] + + +def canvas(): + return [[None] * S for _ in range(S)] + + +def in_round_rect(x, y, x0, y0, x1, y1, r): + """Пиксель внутри прямоугольника со скруглёнными углами (жёсткие углы).""" + if not (x0 <= x <= x1 and y0 <= y <= y1): + return False + for cx, cy in ((x0 + r, y0 + r), (x1 - r, y0 + r), + (x0 + r, y1 - r), (x1 - r, y1 - r)): + if ((x < x0 + r or x > x1 - r) and (y < y0 + r or y > y1 - r)): + dx = x - (x0 + r if x < x0 + r else x1 - r) + dy = y - (y0 + r if y < y0 + r else y1 - r) + if dx * dx + dy * dy > r * r: + return False + return True + + +def fill_round(px, color, x0, y0, x1, y1, r=3): + for y in range(S): + for x in range(S): + if in_round_rect(x, y, x0, y0, x1, y1, r): + px[y][x] = color + + +def speckle(px, region, tones, seed=1, every=5): + """Базовый тон + редкие акценты (1 из every) — без шахматного шума.""" + x0, y0, x1, y1 = region + for y in range(y0, y1 + 1): + for x in range(x0, x1 + 1): + if px[y][x] is None: + continue + n = (x * 7 + y * 13 + seed) % every + px[y][x] = tones[1] if n == 0 else tones[0] + + +def hband(px, y0, y1, row_colors, r=3): + """Горизонтальная полоса строго внутри внутреннего скругления.""" + for y in range(y0, y1 + 1): + c = row_colors[y - y0] + for x in range(1, S - 1): + if in_round_rect(x, y, 1, 1, S - 2, S - 2, r): + px[y][x] = c + + +def stamp_letter(px, gx, gy, colors, outline=None): + """Рисует LETTER_BIG в точке (gx, gy); colors — список цветов по строкам + буквы (1 цвет = весь ряд), outline — контур на 1 пиксель больше.""" + h, w = len(LETTER_BIG), len(LETTER_BIG[0]) + if outline: + for dy in range(-1, h + 1): + for dx in range(-1, w + 1): + inside = 0 <= dy < h and 0 <= dx < w and \ + LETTER_BIG[dy][dx] == "X" + near = inside + if not inside: + for ey in (dy - 1, dy, dy + 1): + for ex in (dx - 1, dx, dx + 1): + if 0 <= ey < h and 0 <= ex < w and \ + LETTER_BIG[ey][ex] == "X": + near = True + if near and not inside: + yy, xx = gy + dy, gx + dx + if 0 <= yy < S and 0 <= xx < S: + px[yy][xx] = outline + for dy, row in enumerate(LETTER_BIG): + for dx, ch in enumerate(row): + if ch == "X": + yy, xx = gy + dy, gx + dx + if 0 <= yy < S and 0 <= xx < S: + px[yy][xx] = colors[min(dy, len(colors) - 1)] + + +def variant_land(): + """Буква-как-блок: трава сверху, земля снизу, тёмный фон.""" + px = canvas() + fill_round(px, FRAME, 0, 0, S - 1, S - 1, 4) + fill_round(px, DARK, 1, 1, S - 2, S - 2, 3) + # фоновая травяная полоса снизу (как на сайте) + hband(px, S - 7, S - 5, [GRASS_D, GRASS, GRASS]) + speckle(px, (2, S - 7, S - 3, S - 5), (GRASS, GRASS_L), seed=3, every=5) + # буква: верх трава, низ земля, между ними тень-кромка + gx, gy = (S - 10) // 2, 5 + colors = ([GRASS_L, GRASS_L] + [GRASS] * 3 + [GRASS_D] + + [DIRT] * 9 + [DIRT_D]) + stamp_letter(px, gx, gy, colors, outline=INK) + return px + + +def variant_gold(): + """Золотая «P» на тёмном слоте с травяной полосой.""" + px = canvas() + fill_round(px, FRAME, 0, 0, S - 1, S - 1, 4) + fill_round(px, DARK, 1, 1, S - 2, S - 2, 3) + gx, gy = (S - 10) // 2, 4 + colors = [GOLD_L, GOLD_L] + [GOLD] * 11 + [GOLD_D] * 3 + stamp_letter(px, gx, gy, colors, outline=INK) + # травяной край снизу + hband(px, S - 6, S - 4, [GRASS_D, GRASS, GRASS]) + speckle(px, (2, S - 6, S - 3, S - 4), (GRASS, GRASS_L), seed=5, every=5) + return px + + +def variant_face(): + """Зелёный блок с тёмной «P» — родственник крипера.""" + px = canvas() + fill_round(px, FRAME, 0, 0, S - 1, S - 1, 4) + fill_round(px, GRASS, 1, 1, S - 2, S - 2, 3) + speckle(px, (1, 1, S - 2, S - 2), (GRASS, GRASS_L), seed=7, every=6) + # грунт снизу — как у травяного блока (кромка, земля, тень) + hband(px, S - 9, S - 2, [GRASS_D] + [DIRT] * 6 + [DIRT_D]) + speckle(px, (1, S - 9, S - 2, S - 2), (DIRT, DIRT_D), seed=11, every=4) + gx, gy = (S - 10) // 2, 5 + stamp_letter(px, gx, gy, [INK] * 16, outline=None) + return px + + +VARIANTS = {"land": variant_land, "gold": variant_gold, "face": variant_face} + + +def rgba(c): + """Всегда RGBA: полупрозрачность/прозрачность углов — 4 канала.""" + return c if len(c) == 4 else (c[0], c[1], c[2], 255) + + +def write_png(path, px, k=K): + h = w = S * k + rows = [] + for y in range(S): + row = [] + for x in range(S): + c = px[y][x] or (0, 0, 0, 0) + row.append(rgba(c)) + for _ in range(k): + line = bytearray(b"\x00") + for c in row: + line += bytes(c) * k + rows.append(bytes(line)) + raw = b"".join(rows) + + def chunk(tag, data): + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff)) + + ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0) + with open(path, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")) + return w, h + + +def preview(name, out_dir): + """Строка из трёх вариантов в 256px + миниатюра 32px рядом (nearest).""" + tiles = [] + for key in ("land", "gold", "face"): + px = VARIANTS[key]() + small = [[px[y][x] or (0, 0, 0, 0) for x in range(S)] + for y in range(S)] + tiles.append((px, small)) + gap = 8 + w = (S * K + gap + S + gap) * 3 + gap + h = S * K + gap * 2 + buf = [[(40, 44, 60, 255)] * w for _ in range(h)] + ox = gap + for px, small in tiles: + for y in range(S * K): + for x in range(S * K): + c = px[y // K][x // K] or (0, 0, 0, 0) + buf[gap + y][ox + x] = rgba(c) + my = gap + (S * K - S) // 2 + for y in range(S): + for x in range(S): + buf[my + y][ox + S * K + gap + x] = rgba(small[y][x]) + ox += S * K + gap + S + gap + rows = [b"\x00" + bytes(v for p in row for v in p) for row in buf] + raw = b"".join(rows) + + def chunk(tag, data): + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff)) + + ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0) + with open(out_dir, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")) + return out_dir + + +if __name__ == "__main__": + here = os.path.dirname(os.path.abspath(__file__)) + if len(sys.argv) >= 3: + px = VARIANTS[sys.argv[1]]() + print(write_png(sys.argv[2], px)) + else: + tmp = os.path.join(os.environ.get("TEMP", here), "logo_preview.png") + preview("all", tmp) + for key in VARIANTS: + out = os.path.join(os.environ.get("TEMP", here), + "logo_%s.png" % key) + print(key, write_png(out, VARIANTS[key]())) + print("preview:", tmp) diff --git a/installer.iss b/installer.iss new file mode 100644 index 0000000..ec31099 --- /dev/null +++ b/installer.iss @@ -0,0 +1,74 @@ +; PixelLauncher — Inno Setup 6 script +; Сборка: python make_installer.py (или: ISCC.exe installer.iss) +; Результат: PixelLauncherSetup2.1.0.exe в папке проекта. +; AppId должен оставаться неизменным, иначе обновление появится +; как отдельная программа. + +#define MyAppName "PixelLauncher" +#define MyAppVersion "2.1.0" +#define MyAppPublisher "SuperBoys" +#define MyAppURL "https://pixellauncher.ru" +#define StageDir "build\stage" +#define MyIcon "build\launcher.ico" + +[Setup] +AppId={{7B3F2A19-5C4E-4D8B-9F61-2E8A0C4D1B55} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +DefaultDirName={localappdata}\{#MyAppName} +DisableProgramGroupPage=yes +; без UAC: лаунчер и .minecraft живут в профиле пользователя +PrivilegesRequired=lowest +OutputDir=. +OutputBaseFilename=PixelLauncherSetup{#MyAppVersion} +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +MinVersion=10.0 +ArchitecturesAllowed=x64compatible +SetupIconFile={#MyIcon} +UninstallDisplayIcon={app}\launcher.ico +UninstallDisplayName={#MyAppName} +VersionInfoVersion=2.1.0.0 +VersionInfoProductVersion={#MyAppVersion} +LZMANumBlockThreads=2 + +[Languages] +Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "Создать ярлык на рабочем столе"; \ + GroupDescription: "Дополнительно:" + +[Files] +Source: "{#StageDir}\*"; DestDir: "{app}"; \ + Flags: recursesubdirs ignoreversion + +[Icons] +; WorkingDir обязателен: настройки и .minecraft ищутся относительно папки +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\pythonw.exe"; \ + Parameters: "main.py"; WorkingDir: "{app}"; \ + IconFilename: "{app}\launcher.ico"; Comment: "Оффлайн-лаунчер Minecraft" +Name: "{userdesktop}\{#MyAppName}"; Filename: "{app}\pythonw.exe"; \ + Parameters: "main.py"; WorkingDir: "{app}"; \ + IconFilename: "{app}\launcher.ico"; Tasks: desktopicon + +[Registry] +; Протокол pixellauncher:// — ссылки с сайта открывает лаунчер +Root: HKCU; Subkey: "Software\Classes\pixellauncher"; \ + ValueType: string; ValueData: "URL:PixelLauncher Protocol"; \ + Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\pixellauncher"; \ + ValueType: string; ValueName: "URL Protocol"; ValueData: "" +Root: HKCU; Subkey: "Software\Classes\pixellauncher\shell\open\command"; \ + ValueType: string; \ + ValueData: """{app}\pythonw.exe"" ""main.py"" ""--protocol-url"" ""%1""" + +[Run] +Filename: "{app}\pythonw.exe"; Parameters: "main.py"; \ + WorkingDir: "{app}"; Description: "Запустить {#MyAppName}"; \ + Flags: nowait postinstall skipifsilent diff --git a/launcher/__init__.py b/launcher/__init__.py new file mode 100644 index 0000000..5a46dee --- /dev/null +++ b/launcher/__init__.py @@ -0,0 +1,4 @@ +"""PixelLauncher — lightweight Minecraft launcher (offline mode).""" + +LAUNCHER_NAME = "PixelLauncher" +LAUNCHER_VERSION = "2.1.0" diff --git a/launcher/config.py b/launcher/config.py new file mode 100644 index 0000000..797d3b7 --- /dev/null +++ b/launcher/config.py @@ -0,0 +1,153 @@ +"""Пути и настройки лаунчера (settings.json рядом с main.py).""" + +import json +import os + +APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SETTINGS_PATH = os.path.join(APP_DIR, "settings.json") +DEFAULT_GAME_DIR = os.path.join(APP_DIR, ".minecraft") + +DEFAULTS = { + "username": "Player", + "version": "latest-release", + "ram": 2048, + "auto_ram": False, + "java_path": "", + "jvm_args": "", + "jvm_preset": "", + "game_dir": DEFAULT_GAME_DIR, + # аккаунты (оффлайн-ники) и активный + "accounts": ["Player"], + "active_account": "Player", + # интерфейс + "lang": "ru", + "theme": "dark", # "dark" | "light" + "accent": "#6fae4a", + "ui_scale": 1.0, + "scene": "day", + # запуск игры + "win_w": 0, # 0 = значение игры по умолчанию + "win_h": 0, + "fullscreen": False, + "auto_backup": False, # резервная копия мира перед запуском + "autostart": False, # ярлык в автозагрузке Windows + "minimize_to_tray_on_launch": False, # прятать окно при запуске игры + # прокси: {"type": "http"|"socks5", "host", "port", "user", "pass"} + "proxy": {"enabled": False, "type": "http", "host": "", "port": 1080, + "user": "", "pass": ""}, + # зеркала скачивания (пустые списки = официальные серверы Mojang) + "asset_mirrors": [], + "lib_mirrors": [], + # третья волна + "fav_versions": [], # закреплённые версии (id) + "profiles": [], # профили запуска (см. ui._open_profiles) + "active_profile": "", # имя применённого профиля ("" — без профиля) + "world_backups": 5, # сколько последних копий мира хранить + # мастер первоначальной настройки (False — показать при первом запуске; + # апгрейд со старой версии считается настроенным, см. load_settings) + "onboarding_done": False, +} + + +def load_settings(): + s = dict(DEFAULTS) + try: + with open(SETTINGS_PATH, encoding="utf-8") as f: + data = json.load(f) + except Exception: + data = None + if isinstance(data, dict): + s.update(data) + if "onboarding_done" not in data: + # апгрейд со старой версии: мастер первого запуска не показываем + s["onboarding_done"] = True + _migrate(s) + return s + + +def _migrate(s): + """Приводит старые настройки к новой структуре.""" + nick = (s.get("username") or "Player").strip() or "Player" + accounts = s.get("accounts") + if not isinstance(accounts, list) or not accounts: + accounts = [nick] + accounts = [str(a).strip() for a in accounts if str(a).strip()] + if nick not in accounts: + accounts.insert(0, nick) + s["accounts"] = accounts + + active = s.get("active_account") + if not isinstance(active, str) or active not in accounts: + s["active_account"] = nick if nick in accounts else accounts[0] + s["username"] = s["active_account"] + + for key in ("asset_mirrors", "lib_mirrors"): + v = s.get(key) + if not isinstance(v, list): + s[key] = [] + if not isinstance(s.get("accent"), str) or not s["accent"].startswith("#"): + s["accent"] = DEFAULTS["accent"] + if s.get("theme") not in ("dark", "light"): + s["theme"] = "dark" + + proxy = s.get("proxy") + if not isinstance(proxy, dict): + proxy = dict(DEFAULTS["proxy"]) + for k, dv in DEFAULTS["proxy"].items(): + if k not in proxy: + proxy[k] = dv + s["proxy"] = proxy + + if s.get("scene") not in ("day", "night", "nether", "cave", "random"): + s["scene"] = "day" + if s.get("ui_scale") not in (1.0, 1.25, 1.5): + try: + sc = float(s.get("ui_scale")) + s["ui_scale"] = sc if sc in (1.0, 1.25, 1.5) else 1.0 + except (TypeError, ValueError): + s["ui_scale"] = 1.0 + for k in ("win_w", "win_h"): + try: + s[k] = max(0, int(s.get(k, 0))) + except (TypeError, ValueError): + s[k] = 0 + s["fullscreen"] = bool(s.get("fullscreen")) + s["auto_backup"] = bool(s.get("auto_backup")) + s["autostart"] = bool(s.get("autostart")) + s["minimize_to_tray_on_launch"] = bool( + s.get("minimize_to_tray_on_launch")) + + fav = s.get("fav_versions") + if isinstance(fav, list): + seen_f, out_f = set(), [] + for v in fav[:200]: + v = str(v).strip() + if v and v not in seen_f: + seen_f.add(v) + out_f.append(v) + s["fav_versions"] = out_f + else: + s["fav_versions"] = [] + profs, names = [], set() + if isinstance(s.get("profiles"), list): + for p in s["profiles"][:100]: + name = str((p or {}).get("name", "")).strip() if isinstance( + p, dict) else "" + if name and name not in names: + names.add(name) + profs.append(p) + s["profiles"] = profs + ap = s.get("active_profile") + s["active_profile"] = ap if isinstance(ap, str) and ap in names else "" + try: + s["world_backups"] = min(50, max(1, int(s.get("world_backups", 5)))) + except (TypeError, ValueError): + s["world_backups"] = 5 + + +def save_settings(s): + try: + with open(SETTINGS_PATH, "w", encoding="utf-8") as f: + json.dump(s, f, indent=2, ensure_ascii=False) + except Exception: + pass diff --git a/launcher/crash.py b/launcher/crash.py new file mode 100644 index 0000000..dfeaeb8 --- /dev/null +++ b/launcher/crash.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +"""Разбор логов вылетов Minecraft. + +Ищет в логе последний exception-блок и определяет причину: +нехватка ОЗУ, проблемы с Java, конкретный мод или «неизвестно». +""" + +import re + +# "java.lang.NullPointerException: ..." / "Caused by: ...Exception: ..." +_EXC = re.compile( + r"^(?:Caused by: |Exception in thread [^\s]+ )?" + r"([\w.$]+(?:Exception|Error|Throwable))(?::\s*(.*))?$") + +_MOD_JAR = re.compile(r"mods[/\\]([\w.\- ]+\.jar)") +_MOD_AT = re.compile(r"\(([\w\-]+)@[\d.]+\)") # SomeMod@1.2.3 в кадрах +_XMX = re.compile(r"-xmx(\d+)([mg])") + + +def analyze(text=None, path=None, tail=600): + """Разбирает лог. Возвращает dict: + + kind — "crash" | "oom" | "java" | "unknown" + exc — класс исключения (без пакета) + msg — сообщение исключения + culprit — имя файла мода (mods/xxx.jar) или id мода, если найдено + hints — подсказки ("oom", "xmx=2048", "classver", ...) + """ + res = {"kind": "unknown", "exc": "", "msg": "", "culprit": None, + "hints": []} + lines = [] + if text is not None: + lines = text.splitlines()[-tail:] + elif path: + try: + with open(path, encoding="utf-8", errors="replace") as f: + lines = f.readlines()[-tail:] + except OSError: + pass + joined = "\n".join(lines) + if not joined.strip(): + return res + low = joined.lower() + + # --- нехватка ОЗУ + if ("outofmemoryerror" in low + or "could not reserve enough space" in low + or "gc overhead limit exceeded" in low): + res["kind"] = "oom" + res["exc"] = "OutOfMemoryError" + res["hints"].append("oom") + m = _XMX.search(low) + if m: + res["hints"].append("xmx=" + m.group(1) + m.group(2)) + return res + + # --- проблемы с Java + for pat, tag in ((r"unsupported class file major version (\d+)", + "classver"), + (r"unsupportedclassversionerror", "classver"), + (r"could not create the java virtual machine", "jvm"), + (r"invalid java runtime|no jvm could be found", + "java")): + if re.search(pat, low): + res["kind"] = "java" + res["exc"] = tag + res["hints"].append(tag) + return res + + # --- последний exception-блок + idx = [i for i, ln in enumerate(lines) if _EXC.match(ln.strip())] + if not idx: + return res + caused = [i for i in idx if lines[i].strip().startswith("Caused by:")] + # корневая причина — последний Caused by в цепочке + m = _EXC.match(lines[caused[-1] if caused else idx[0]].strip()) + res["kind"] = "crash" + if m: + res["exc"] = m.group(1).split(".")[-1] + res["msg"] = (m.group(2) or "")[:200] + + # --- виновник: файл мода в тексте или mixin-кадр с mod id + m2 = _MOD_JAR.search(joined) + if m2: + res["culprit"] = m2.group(1) + else: + m3 = _MOD_AT.search(joined) + if m3: + res["culprit"] = m3.group(1) + return res diff --git a/launcher/downloader.py b/launcher/downloader.py new file mode 100644 index 0000000..64454d0 --- /dev/null +++ b/launcher/downloader.py @@ -0,0 +1,225 @@ +"""Скачивание файлов игры: client jar, библиотеки, нативы, ассеты.""" + +import json +import os +import shutil +import zipfile +from concurrent.futures import as_completed, ThreadPoolExecutor + +from . import http + +LIB_WORKERS = 16 +ASSET_WORKERS = 32 +ASSET_BASE = "https://resources.download.minecraft.net/" + + +def _mirror_urls(mirrors, default_base, url): + """Кандидаты для скачивания: зеркала (если подходят) + официальный URL.""" + urls = [] + for m in mirrors or []: + m = (m or "").strip().rstrip("/") + if m and url.startswith(default_base): + urls.append(m + "/" + url[len(default_base):]) + urls.append(url) + return urls + + +def _download_many(tasks, workers=LIB_WORKERS, progress=None, verify=False, + cancel=None): + """tasks: список (urls, path, sha1, size) — urls по приоритету (зеркала). + + progress(done, total). verify=True — полная проверка SHA-1. + cancel — threading.Event: досрочная оставка (http.Cancelled). + """ + total = len(tasks) + errors = [] + if not tasks: + return errors + + def work(task): + urls, path, sha1, size = task + last = None + for url in urls: + if cancel is not None and cancel.is_set(): + raise http.Cancelled() + try: + return http.download_file(url, path, sha1, size, verify, + cancel=cancel) + except http.Cancelled: + raise + except Exception as e: # noqa: BLE001 + last = e + return RuntimeError(f"{urls[-1] if urls else '?'}: {last}") + + with ThreadPoolExecutor(max_workers=workers) as ex: + futs = [ex.submit(work, t) for t in tasks] + done = 0 + for fut in as_completed(futs): + if cancel is not None and cancel.is_set(): + for f2 in futs: + f2.cancel() + raise http.Cancelled() + done += 1 + try: + res = fut.result() + if isinstance(res, Exception): + errors.append(str(res)) + except http.Cancelled: + raise + except Exception as e: # noqa: BLE001 + errors.append(str(e)) + if progress: + progress(done, total) + return errors + + +def extract_natives(version, libs_dir): + """Распаковывает natives-jar'ы в папку нативов версии. + + Если набор jar'ов не менялся (сигнатура в .stamp), распаковка + пропускается — на повторных запусках это экономит время. + """ + dest = version.natives_dir() + os.makedirs(dest, exist_ok=True) + stamp_path = os.path.join(dest, ".stamp") + + jars, sig_parts = [], [] + for rel in version.natives_jars: + jar = os.path.join(libs_dir, *rel.split("/")) + if not os.path.isfile(jar): + continue + jars.append(jar) + st = os.stat(jar) + sig_parts.append(f"{rel}:{st.st_size}:{int(st.st_mtime)}") + sig = ";".join(sig_parts) + + try: + with open(stamp_path, encoding="utf-8") as f: + if f.read() == sig: + return dest + except OSError: + pass + + for jar in jars: + try: + with zipfile.ZipFile(jar) as z: + for info in z.infolist(): + name = info.filename + if name.endswith("/") or name.upper().startswith("META-INF/"): + continue + target = os.path.join(dest, *name.split("/")) + os.makedirs(os.path.dirname(target), exist_ok=True) + with z.open(info) as src, open(target, "wb") as dst: + shutil.copyfileobj(src, dst) + except (zipfile.BadZipFile, OSError): + continue + + try: + with open(stamp_path, "w", encoding="utf-8") as f: + f.write(sig) + except OSError: + pass + return dest + + +def _ensure_assets(version, progress=None, verify=False, cancel=None, + mirrors=None): + ai = version.asset_index + if not ai.get("url"): + return + assets = version.assets_dir() + idx_id = ai.get("id", "legacy") + idx_path = os.path.join(assets, "indexes", idx_id + ".json") + http.download_file(ai["url"], idx_path, ai.get("sha1"), ai.get("size"), + cancel=cancel) + + with open(idx_path, encoding="utf-8") as f: + index = json.load(f) + + base = ASSET_BASE + tasks = [] + objects = index.get("objects") or {} + for name, obj in objects.items(): + h = obj["hash"] + url = base + h[:2] + "/" + h + path = os.path.join(assets, "objects", h[:2], h) + tasks.append((_mirror_urls(mirrors, base, url), path, h, + obj.get("size"))) + + errors = _download_many(tasks, workers=ASSET_WORKERS, progress=progress, + verify=verify, cancel=cancel) + + # Виртуальные ассеты для старых версий (1.6 и раньше: virtual / map_to_resources) + if index.get("virtual"): + vdir = os.path.join(assets, "virtual", idx_id) + for name, obj in objects.items(): + src = os.path.join(assets, "objects", obj["hash"][:2], obj["hash"]) + dst = os.path.join(vdir, *name.split("/")) + os.makedirs(os.path.dirname(dst), exist_ok=True) + if not os.path.isfile(dst): + try: + shutil.copy2(src, dst) + except OSError: + pass + if index.get("map_to_resources"): + rdir = os.path.join(version.game_dir, "resources") + for name, obj in objects.items(): + src = os.path.join(assets, "objects", obj["hash"][:2], obj["hash"]) + dst = os.path.join(rdir, *name.split("/")) + os.makedirs(os.path.dirname(dst), exist_ok=True) + if not os.path.isfile(dst): + try: + shutil.copy2(src, dst) + except OSError: + pass + return errors + + +def ensure(version, progress=None, asset_progress=None, log=None, verify=False, + cancel=None, asset_mirrors=None, lib_mirrors=None): + """Скачивает всё необходимое для запуска. Возвращает (errors, natives_dir). + + verify=True — полная проверка SHA-1 уже скачанных файлов + (иначе достаточно совпадения размера). + cancel — threading.Event: досрочная остановка (бросает http.Cancelled). + asset_mirrors / lib_mirrors — списки зеркал (пусто = официальные серверы). + """ + libs_dir = version.libraries_dir() + errors = [] + lib_base = "https://libraries.minecraft.net/" + + tasks = [] + if version.client_url: + tasks.append(([version.client_url], version.client_jar_path(), + version.client_sha1, version.client_size)) + for lib in version.libraries: + path = os.path.join(libs_dir, *lib["path"].split("/")) + tasks.append((_mirror_urls(lib_mirrors, lib_base, lib["url"]), path, + lib.get("sha1"), lib.get("size"))) + + if log: + log(f"Скачивание библиотек ({len(tasks)} шт.)...") + errors += _download_many(tasks, progress=progress, verify=verify, + cancel=cancel) + + natives = extract_natives(version, libs_dir) + + lf = version.logging.get("file") or {} + if lf.get("url") and lf.get("id"): + cfg_dir = os.path.join(version.assets_dir(), "log_configs") + try: + http.download_file(lf["url"], os.path.join(cfg_dir, lf["id"]), + lf.get("sha1"), lf.get("size"), cancel=cancel) + except http.Cancelled: + raise + except Exception as e: # noqa: BLE001 + errors.append(f"log config: {e}") + + if version.asset_index.get("url"): + if log: + log("Скачивание ассетов...") + errors += _ensure_assets(version, progress=asset_progress, + verify=verify, cancel=cancel, + mirrors=asset_mirrors) or [] + + return errors, natives diff --git a/launcher/forge.py b/launcher/forge.py new file mode 100644 index 0000000..decaf13 --- /dev/null +++ b/launcher/forge.py @@ -0,0 +1,199 @@ +"""Установка Forge и NeoForge официальными инсталляторами. + +Инсталлятор скачивается с maven, запускается нашей Java и сам ставит +библиотеки и JSON профиля в папку игры — готовая версия появляется в +списке так же, как Fabric/Quilt. +""" + +import json +import os +import re +import subprocess +import threading + +from . import http, java as java_mod, java_fetch +from . import manifest as manifest_mod, version as version_mod + +LOADERS = ("forge", "neoforge") +META = { + "forge": ("https://maven.minecraftforge.net/net/minecraftforge/" + "forge/maven-metadata.xml"), + "neoforge": ("https://maven.neoforged.net/releases/net/neoforged/" + "neoforge/maven-metadata.xml"), +} +INSTALLER = { + "forge": ("https://maven.minecraftforge.net/net/minecraftforge/forge/" + "{v}/forge-{v}-installer.jar"), + "neoforge": ("https://maven.neoforged.net/releases/net/neoforged/" + "neoforge/{v}/neoforge-{v}-installer.jar"), +} + +_cache = {} # loader -> [версия лоадера] + + +def _num(s): + """'47.2.0' -> (47, 2, 0): числа из строки для сравнения версий.""" + return tuple(int(x) for x in re.findall(r"\d+", s)) or (0,) + + +def _meta(loader): + if loader not in _cache: + raw = http.get_bytes(META.get(loader, META["forge"]), timeout=30) + text = raw.decode("utf-8", "replace") + _cache[loader] = re.findall(r"\s*([^<]+?)\s*", text) + return _cache[loader] + + +def _mc_of(loader, v): + """MC-версия, под которую выпущена версия лоадера (или None).""" + if loader == "forge": + # 1.20.1-47.2.0 -> 1.20.1 + return v.split("-", 1)[0] + # neoforge: 21.1.99 -> 1.21.1, 21.0.17 -> 1.21, 20.1.117 -> 1.20.1 + m = re.match(r"^(\d+)\.(\d+)(?:\.|$)", v) + if not m: + return None + return "1." + m.group(1) + ("" if m.group(2) == "0" else "." + m.group(2)) + + +def game_versions(loader): + """[(id, stable)] MC-версий, под которые есть лоадер (новые первые).""" + out = {} + for v in _meta(loader): + if loader == "neoforge" and not re.match(r"^\d+(\.\d+)+$", v): + continue # alpha/beta/+snapshot-сборки пропускаем + mc = _mc_of(loader, v) + if not mc or not re.match(r"^1\.\d+(\.\d+)?$", mc): + continue + if int(mc.split(".")[1]) < 7: # древности не показываем + continue + out.setdefault(mc, True) + return sorted(out.items(), key=lambda kv: _num(kv[0]), reverse=True) + + +def loader_versions(loader, game_version): + """Версии лоадера для выбранной игры (новые первые).""" + out = [] + for v in _meta(loader): + if loader == "forge": + if v.startswith(game_version + "-"): + out.append(v) + elif _mc_of(loader, v) == game_version and \ + re.match(r"^\d+(\.\d+)+$", v): + out.append(v) + if loader == "forge": + out.sort(key=lambda v: _num(v[len(game_version) + 1:]), reverse=True) + else: + out.sort(key=_num, reverse=True) + return out + + +def _snap(ver_dir): + """{имя версии: mtime её JSON} — снимок до запуска инсталлятора.""" + out = {} + try: + for name in os.listdir(ver_dir): + try: + out[name] = os.path.getmtime( + os.path.join(ver_dir, name, name + ".json")) + except OSError: + out[name] = 0 + except OSError: + pass + return out + + +def _changed(ver_dir, before): + """Имя версии, которая появилась или обновилась (или None).""" + after = _snap(ver_dir) + fresh = sorted((n for n in after if n not in before), + key=lambda n: after[n], reverse=True) + if fresh: + return fresh[0] + upd = sorted((n for n in after + if n in before and after[n] > before[n] + 0.5), + key=lambda n: after[n], reverse=True) + return upd[0] if upd else None + + +def _ensure_profiles(game_dir): + """Старые инсталляторы требуют launcher_profiles.json — создаём заглушку.""" + p = os.path.join(game_dir, "launcher_profiles.json") + if os.path.isfile(p): + return + data = {"profiles": {"PixelLauncher": { + "name": "PixelLauncher", "type": "custom", + "gameDir": game_dir, "lastVersionId": ""}}, + "clientToken": "0" * 32, "selectedUser": "", + "authenticationDatabase": {}, "launcherVersion": "1.6.84"} + with open(p, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + +def _java_for(game_dir, game_version, say): + """Java под требования версии — та же логика, что при обычном запуске.""" + req = 8 + try: + info = version_mod.load(game_dir, game_version, net=True) + req = max(int(getattr(info, "java_major", 0) or 0), 8) + except Exception: + # JSON ванильной версии всё равно нужен инсталлятору — достаём + manifest_mod.fetch_version_json(game_dir, game_version, net=True) + runtime = os.path.join(game_dir, ".launcher", "runtime") + preferred = java_fetch.find_installed(runtime, req) + jpath, _jver, ok = java_mod.pick_java( + req, preferred, + cache_path=os.path.join(game_dir, ".launcher", "java_cache.json")) + if not jpath or not ok: + say("Скачиваю Java {r}...".format(r=req)) + jpath = java_fetch.ensure_java( + req, runtime, log=lambda m, t=None: say(str(m)), + cancel=threading.Event()) + if not jpath: + raise RuntimeError("не удалось подобрать Java " + str(req)) + return jpath + + +def install(game_dir, loader, game_version, loader_version=None, log=None): + """Скачивает и запускает официальный инсталлятор. Возвращает id версии.""" + say = log or (lambda m: None) + if loader not in LOADERS: + raise RuntimeError("неизвестный лоадер " + str(loader)) + vs = loader_versions(loader, game_version) + if not vs: + raise RuntimeError("нет версий {0} для {1}".format(loader, game_version)) + if loader_version not in vs: + loader_version = vs[0] + + jpath = _java_for(game_dir, game_version, say) + + cache = os.path.join(game_dir, ".launcher", "loaders") + os.makedirs(cache, exist_ok=True) + jar = os.path.join(cache, + "{0}-{1}-installer.jar".format(loader, loader_version)) + say("Скачивание инсталлятора {0} {1}...".format(loader, loader_version)) + http.download_file(INSTALLER[loader].format(v=loader_version), jar) + + _ensure_profiles(game_dir) + ver_dir = os.path.join(game_dir, "versions") + before = _snap(ver_dir) + say("Инсталлятор запускается — может занять несколько минут...") + flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + try: + proc = subprocess.run( + [jpath, "-jar", jar, "--installClient", game_dir], + capture_output=True, text=True, errors="replace", + timeout=900, creationflags=flags) + except subprocess.TimeoutExpired: + raise RuntimeError("инсталлятор не уложился в 15 минут") from None + if proc.returncode != 0: + lines = [x.strip() for x in + ((proc.stdout or "") + (proc.stderr or "")).splitlines() + if x.strip()] + tail = "; ".join(lines)[-500:] + raise RuntimeError("инсталлятор завершился с ошибкой" + + (": " + tail if tail else "")) + vid = _changed(ver_dir, before) + if not vid: + raise RuntimeError("инсталлятор отработал, но новая версия не найдена") + return vid diff --git a/launcher/http.py b/launcher/http.py new file mode 100644 index 0000000..362a47c --- /dev/null +++ b/launcher/http.py @@ -0,0 +1,314 @@ +"""Минимальные HTTP-хелперы на urllib (без сторонних библиотек). + +Поддерживает прокси: обычный HTTP-прокси и SOCKS5 (собственная реализация +рукопожатия — stdlib не умеет SOCKS без PySocks). +""" + +import hashlib +import http.client +import json +import os +import socket +import struct +import time +import urllib.request + +USER_AGENT = "PixelLauncher/2.1.0 (lightweight Minecraft launcher)" +ATTEMPTS = 4 + +_opener = None # настроенный opener (прокси) или None — напрямую в сеть + + +class Cancelled(Exception): + """Операция отменена пользователем.""" + + +# --------------------------------------------------------------- зеркала API +# Своё зеркало на VPS (nginx-прокси): используется одной попыткой — не +# ответило, зеркало отключается до конца сессии и запросы идут напрямую. +MIRRORS = { + "https://api.modrinth.com/": "https://pixellauncher.ru/proxy/modrinth/", + "https://piston-meta.mojang.com/": "https://pixellauncher.ru/proxy/mojang/", +} +_mirror_broken = set() # префиксы с отключённым зеркалом (до конца сессии) + + +def _mirror(url): + """(зеркальный url, префикс) либо (None, None).""" + for prefix, mirror in MIRRORS.items(): + if url.startswith(prefix) and prefix not in _mirror_broken: + return mirror + url[len(prefix):], prefix + return None, None + + +def _attempt_urls(url): + """Очередь URL для попыток: зеркало один раз, затем оригинал.""" + mirror, prefix = _mirror(url) + if mirror: + yield mirror, prefix + for _ in range(ATTEMPTS): + yield url, None + + +def reset_mirrors(): + """Возвращает зеркала в строй (для тестов/новой сессии).""" + _mirror_broken.clear() + + +# ------------------------------------------------------------------ прокси +def configure(proxy=None): + """Настраивает исходящие запросы. proxy — dict из settings["proxy"]. + + {"enabled": True, "type": "http"|"socks5", "host", "port", "user", "pass"} + Пусто/выключено — прямое соединение. + """ + global _opener + _opener = None + if not isinstance(proxy, dict) or not proxy.get("enabled"): + return + host = (proxy.get("host") or "").strip() + try: + port = int(proxy.get("port") or 0) + except (TypeError, ValueError): + port = 0 + if not host or not port: + return + user = (proxy.get("user") or "").strip() + passwd = proxy.get("pass") or "" + + if (proxy.get("type") or "http") == "socks5": + _opener = urllib.request.build_opener( + *_make_socks_handlers(host, port, user, passwd)) + else: + # креды в URL: их подхватывает _parse_proxy и шлёт в туннеле CONNECT + addr = f"http://{host}:{port}" + if user: + from urllib.parse import quote + addr = f"http://{quote(user, safe='')}:{quote(passwd, safe='')}@" \ + f"{host}:{port}" + _opener = urllib.request.build_opener( + urllib.request.ProxyHandler({"http": addr, "https": addr})) + + +def _socks_handshake(sock, host, port, auth): + """SOCKS5: рукопожатие + CONNECT к host:port. Оставляет sock готовым.""" + if auth and auth[0]: + sock.sendall(b"\x05\x02\x00\x02") # предлагаем без аутентификации и с логином + resp = _socks_recv(sock, 2) + if resp == b"\x05\x02": + u, p = auth[0].encode(), (auth[1] or "").encode() + sock.sendall(b"\x01" + bytes([len(u)]) + u + bytes([len(p)]) + p) + r = _socks_recv(sock, 2) + if r[1] != 0: + raise OSError("SOCKS5: неверный логин/пароль") + elif resp != b"\x05\x00": + raise OSError("SOCKS5: сервер отклонил метод аутентификации") + else: + sock.sendall(b"\x05\x01\x00") + resp = _socks_recv(sock, 2) + if resp != b"\x05\x00": + raise OSError("SOCKS5: сервер не поддержал без аутентификации") + hb = host.encode() + sock.sendall(b"\x05\x01\x00\x03" + bytes([len(hb)]) + hb + + struct.pack("!H", port or 0)) + head = _socks_recv(sock, 4) + if head[1] != 0: + raise OSError(f"SOCKS5: отказ подключения (код {head[1]})") + atyp = head[3] + if atyp == 1: # IPv4 + _socks_recv(sock, 4) + elif atyp == 3: # домен + _socks_recv(sock, 1 + _socks_recv(sock, 1)[0]) + elif atyp == 4: # IPv6 + _socks_recv(sock, 16) + _socks_recv(sock, 2) + + +def _socks_recv(sock, n): + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise OSError("SOCKS5: соединение закрыто") + buf += chunk + return buf + + +def _make_socks_handlers(host, port, user, passwd): + """Handler'ы, подменяющие соединение на SOCKS5-туннель.""" + + class _SocksConn: + PROXY = (host, port) + AUTH = (user, passwd) + + def connect(self): + sock = socket.create_connection(self.PROXY, timeout=self.timeout) + _socks_handshake(sock, self.host, self.port or 0, self.AUTH) + self.sock = sock + + class C(_SocksConn, http.client.HTTPConnection): + pass + + class HC(_SocksConn, http.client.HTTPSConnection): + def connect(self): + _SocksConn.connect(self) + self.sock = self._context.wrap_socket(self.sock, + server_hostname=self.host) + + class H1(urllib.request.HTTPHandler): + def http_open(self, req): + return self.do_open(C, req) + + class H2(urllib.request.HTTPSHandler): + def https_open(self, req): + return self.do_open(HC, req) + + return H1(), H2() + + +def _open(req, timeout=30): + """urlopen через настроенный opener (прокси, если задан).""" + if _opener is not None: + return _opener.open(req, timeout=timeout) + return urllib.request.urlopen(req, timeout=timeout) + + +def get_bytes(url, timeout=30): + last = None + n = 0 + for u, mprefix in _attempt_urls(url): + try: + req = urllib.request.Request(u, headers={"User-Agent": USER_AGENT}) + with _open(req, timeout=timeout) as r: + return r.read() + except Exception as e: # noqa: BLE001 + last = e + if mprefix: + _mirror_broken.add(mprefix) # молчит — до конца сессии + continue + n += 1 + time.sleep(0.7 * n) + raise RuntimeError(f"Не удалось скачать {url}: {last}") + + +def post_json(url, obj, timeout=30): + """POST с JSON-телом. Возвращает распарсенный JSON.""" + body = json.dumps(obj).encode("utf-8") + last = None + n = 0 + for u, mprefix in _attempt_urls(url): + try: + req = urllib.request.Request( + u, data=body, method="POST", + headers={"User-Agent": USER_AGENT, + "Content-Type": "application/json"}) + with _open(req, timeout=timeout) as r: + return json.loads(r.read().decode("utf-8")) + except Exception as e: # noqa: BLE001 + last = e + if mprefix: + _mirror_broken.add(mprefix) # молчит — до конца сессии + continue + n += 1 + time.sleep(0.7 * n) + raise RuntimeError(f"Не удалось отправить запрос {url}: {last}") + + +def head_size(url, timeout=20): + """Размер файла по HEAD-запросу (для прогресса), None если неизвестен.""" + for method in ("HEAD", "GET"): + try: + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}, + method=method) + with _open(req, timeout=timeout) as r: + n = r.headers.get("Content-Length") + if n: + return int(n) + if method == "HEAD": + continue # пробуем GET: часть серверов не даёт длину в HEAD + return None + except Exception: # noqa: BLE001 + continue + return None + + +def sha1_file(path): + h = hashlib.sha1() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 16), b""): + h.update(chunk) + return h.hexdigest() + + +def download_file(url, path, sha1=None, size=None, verify_sha1=False, + progress=None, cancel=None): + """Скачивает файл только если он отсутствует/битый. + + Существующий файл проверяется по размеру (быстро, без чтения диска); + полная проверка SHA-1 выполняется только при verify_sha1 (принудительная + проверка целостности) и всегда — для только что скачанных файлов. + progress(done_bytes, total_bytes|None) — вызывается по мере скачивания. + cancel — threading.Event: если задан, скачивание прерывается с Cancelled. + True — файл был скачан, False — уже был на месте. + """ + if os.path.isfile(path): + if verify_sha1 and sha1: + ok = sha1_file(path) == sha1.lower() + elif size: + ok = os.path.getsize(path) == size + else: + ok = True + if ok: + return False + + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".part" + last = None + n = 0 + for u, mprefix in _attempt_urls(url): + if cancel is not None and cancel.is_set(): + raise Cancelled(url) + try: + req = urllib.request.Request(u, headers={"User-Agent": USER_AGENT}) + with _open(req, timeout=90) as r: + total = size + if total is None: + n = r.headers.get("Content-Length") + total = int(n) if n else None + done = 0 + with open(tmp, "wb") as f: + while True: + if cancel is not None and cancel.is_set(): + raise Cancelled(url) + chunk = r.read(1 << 18) + if not chunk: + break + f.write(chunk) + done += len(chunk) + if progress: + progress(done, total) + if sha1 and sha1_file(tmp) != sha1.lower(): + raise RuntimeError("SHA-1 не совпал, файл битый") + os.replace(tmp, path) + return True + except Cancelled: + _remove_tmp(tmp) + raise + except Exception as e: # noqa: BLE001 + last = e + if mprefix: + _mirror_broken.add(mprefix) # молчит — до конца сессии + _remove_tmp(tmp) + continue + n += 1 + time.sleep(0.7 * n) + _remove_tmp(tmp) + raise RuntimeError(f"Не удалось скачать {url}: {last}") + + +def _remove_tmp(tmp): + try: + if os.path.exists(tmp): + os.remove(tmp) + except OSError: + pass diff --git a/launcher/i18n.py b/launcher/i18n.py new file mode 100644 index 0000000..a571e01 --- /dev/null +++ b/launcher/i18n.py @@ -0,0 +1,483 @@ +"""Локализация интерфейса (RU/EN). + +Ключи — исходные русские строки; неизвестные ключи возвращаются как есть, +поэтому непереведённые строки просто отображаются по-русски. +""" + +LANG = "ru" + +# Русский текст -> English +_TABLE = { + # --- главное окно + "PixelLauncher": "PixelLauncher", + "ИГРАТЬ": "PLAY", + "ОТМЕНА": "CANCEL", + "Готово к запуску": "Ready to play", + "Minecraft …": "Minecraft …", + "ПОСЛЕДНИЕ ВЕРСИИ": "LATEST VERSIONS", + "загрузка…": "loading…", + "Ночной пейзаж": "Night landscape", + "Дневной пейзаж": "Day landscape", + "Загружено версий: ": "Versions loaded: ", + + # --- меню (шестерёнка) + "Профиль…": "Profile…", + "Аккаунты…": "Accounts…", + "Выбрать версию…": "Select version…", + "Установить Fabric/Quilt…": "Install Fabric/Quilt…", + "Резервная копия миров": "Back up worlds", + "Скачать эту версию": "Download this version", + "Проверить файлы игры…": "Verify game files…", + "Очистка версий…": "Version cleanup…", + "Журнал…": "Log…", + "Настройки…": "Settings…", + "О программе…": "About…", + + # --- диалоги общие + "Сохранить": "Save", + "Отмена": "Cancel", + "Закрыть": "Close", + "Выбрать": "Select", + "Удалить": "Delete", + "Установить": "Install", + "Обновить список": "Refresh list", + "Открыть": "Open", + + # --- профиль + "Профиль игрока": "Player profile", + "Ник:": "Nickname:", + "ОЗУ (МБ):": "RAM (MB):", + "Java:": "Java:", + "JVM-аргументы:": "JVM arguments:", + "Пусто = подбор Java автоматически.": "Empty = auto-detect Java.", + "Ник: 3-16 символов, латиница, цифры и _": "Nickname: 3-16 chars, Latin letters, digits and _", + "Профиль сохранён: ": "Profile saved: ", + "Авто (по памяти системы)": "Auto (by system RAM)", + + # --- аккаунты + "Аккаунты": "Accounts", + "Добавить аккаунт…": "Add account…", + "Удалить аккаунт": "Delete account", + "Нельзя удалить последний аккаунт": "Cannot delete the last account", + "Аккаунт переключён: ": "Switched to account: ", + + # --- выбор версии + "Выбор версии": "Select version", + "Фильтр по имени · двойной клик — выбрать версию": "Filter by name · double-click to select", + "Версия выбрана: ": "Version selected: ", + "[снапшот]": "[snapshot]", + "[локальная]": "[custom]", + "загрузка…": "loading…", + "релиз": "release", + "снапшот": "snapshot", + + # --- Fabric/Quilt + "Установка Fabric/Quilt": "Install Fabric/Quilt", + "Установка лоадера": "Install mod loader", + "Установить лоадер…": "Install mod loader…", + "Нет версий лоадера для этой игры": "No loader versions for this game", + "Выберите версию лоадера": "Select a loader version", + "Инсталлятор сам скачает библиотеки и профиль — может занять несколько минут.": "The installer downloads the libraries and profile itself — may take a few minutes.", + "Загрузка списка версий лоадера...": "Loading loader version list...", + "Игровая версия:": "Game version:", + "Версия лоадера:": "Loader version:", + "Лоадер:": "Loader:", + "Fabric": "Fabric", + "Quilt": "Quilt", + "Ставится только JSON профиля — файлы докачаются при запуске.": "Only the profile JSON is installed — files are downloaded on launch.", + "Установлено: ": "Installed: ", + "Не удалось получить список версий лоадера: ": "Failed to load loader versions: ", + + # --- настройки + "Настройки": "Settings", + "Папка игры:": "Game folder:", + "Здесь хранятся .minecraft, миры и файлы игры.": "This is where .minecraft, worlds and game files live.", + "Открыть папку игры": "Open game folder", + "Открыть папку миров": "Open saves folder", + "Открыть скриншоты": "Open screenshots", + "Галерея скриншотов…": "Screenshot gallery…", + "Галерея": "Gallery", + "Скриншотов пока нет": "No screenshots yet", + "Открыть файл": "Open file", + "Удалить скриншот {p}?": "Delete screenshot {p}?", + "Скриншот удалён: ": "Screenshot deleted: ", + "Не удалось открыть скриншот": "Failed to open screenshot", + "Не удалось удалить скриншот": "Failed to delete screenshot", + "Импорт модпака: ": "Importing modpack: ", + "Мод добавлен: ": "Mod added: ", + "Не удалось добавить мод": "Failed to add mod", + "Плагин добавлен: ": "Plugin added: ", + "Не удалось добавить плагин": "Failed to add plugin", + "Файл не подходит для лаунчера: ": "This file doesn't fit the launcher: ", + "Есть обновления модов: {n}. Откройте «Моды» для обновления.": "{n} mod updates available. Open Mods to update.", + "Запуск:": "Startup:", + "С автозагрузкой Windows": "With Windows autostart", + "Прятать в трей при запуске игры": "Hide to tray when game starts", + "Автозагрузка отключена": "Autostart disabled", + "Автозагрузка включена: ": "Autostart enabled: ", + "Не удалось изменить автозагрузку": "Failed to change autostart", + "Быстрые настройки": "Quick settings", + "Светлая тема": "Light theme", + "Каталог модпаков": "Modpack Catalog", + "Каталог модпаков…": "Modpack Catalog…", + "Модпаков в каталоге: ": "Modpacks in catalog: ", + "Скачивание: ": "Downloading: ", + "Дождитесь окончания текущей загрузки": + "Wait for the current download to finish", + "Язык:": "Language:", + "Тема:": "Theme:", + "Тёмная": "Dark", + "Светлая": "Light", + "Акцентный цвет:": "Accent color:", + "Зеркала ресурсов (по одному в строке, пусто = официальные):": "Resource mirrors (one per line, empty = official):", + "Зеркала библиотек (по одному в строке, пусто = официальные):": "Library mirrors (one per line, empty = official):", + + # --- журнал / статус + "Журнал": "Log", + "Игра запущена, PID ": "Game started, PID ", + "Приятной игры!": "Have fun!", + "Вывод игры пишется в ": "Game output is written to ", + "Получение манифеста версий...": "Fetching version manifest...", + "Версия: ": "Version: ", + "Java не найдена. Установите Java или укажите путь в профиле": "Java not found. Install Java or set its path in the profile", + "Java не найдена, скачиваю...": "Java not found, downloading...", + "Скачивание Java {v} (Adoptium)...": "Downloading Java {v} (Adoptium)...", + "Распаковка Java...": "Extracting Java...", + "Не удалось распаковать Java": "Failed to extract Java", + "Java {v} установлена": "Java {v} installed", + "Не удалось скачать Java: ": "Failed to download Java: ", + "Скачивание файлов игры...": "Downloading game files...", + "Проверка целостности файлов (SHA-1)...": "Verifying game files (SHA-1)...", + "Скачивание библиотек (": "Downloading libraries (", + "Скачивание ассетов...": "Downloading assets...", + "Файлы": "Files", + "Ассеты": "Assets", + "Java": "Java", + "Загрузка отменена": "Download cancelled", + "Отмена...": "Cancelling...", + "Все файлы на месте, повреждений не найдено.": "All files are in place, no corruption found.", + "Версия скачана и готова к запуску.": "Version downloaded and ready to play.", + "Ошибка: ": "Error: ", + "Ошибок скачивания: ": "Download errors: ", + + # --- вылет игры + "Игра аварийно завершилась (код {c}). Хвост лога:": "The game crashed (code {c}). Log tail:", + "Игра аварийно завершилась": "Game crashed", + "Полный лог: ": "Full log: ", + "Игра завершилась": "Game exited", + + # --- бэкап + "Создание резервной копии: ": "Creating backup: ", + "файлов...": " files...", + "Копия создана: ": "Backup created: ", + "Папка миров (saves) не найдена": "Worlds folder (saves) not found", + + # --- очистка версий + "Очистка версий": "Version cleanup", + "Считаю размеры...": "Calculating sizes...", + "Удалить выбранную версию": "Delete selected version", + "Удалить неиспользуемые ассеты": "Delete unused assets", + "Удалить неиспользуемые ассеты: ": "Deleted unused assets: ", + "ГБ освобождено": " GB freed", + "Версия используется как выбранная — смените её в списке.": "This version is currently selected — pick another one first.", + "Удалить версию {v}?": "Delete version {v}?", + "Версия удалена: ": "Version deleted: ", + "Удалено неиспользуемых ассетов: ": "Unused assets removed: ", + "МБ": "MB", + + # --- о программе + "О программе": "About", + "Компактный оффлайн-лаунчер Minecraft\nв стиле LiteLauncher.\n\n" + "• Все версии из манифеста Mojang\n" + "• Автодокачка файлов с проверкой SHA-1\n" + "• Автоподбор Java, копии миров\n" + "• Без сторонних библиотек": + "Compact offline Minecraft launcher\nin the spirit of LiteLauncher.\n\n" + "• Every version from the Mojang manifest\n" + "• Auto-download with SHA-1 checks\n" + "• Java auto-detect, world backups\n" + "• No third-party libraries", + "Манифест Mojang": "Mojang manifest", + + # --- прочее + "Не удалось загрузить список версий: ": "Failed to load version list: ", + "Не удалось получить список версий: ": "Failed to load versions: ", + "Не удалось разобрать JSON версии": "Failed to parse version JSON", + "Настройки сохранены": "Settings saved", + "Копировать": "Copy", + "Копирование": "Copying", + "Выберите java.exe": "Select java.exe", + "Все файлы": "All files", + "Лоадер:": "Loader:", + "Установка...": "Installing...", + "Поиск неиспользуемых ассетов...": "Searching for unused assets...", + "Java {jver} найдена": "Java {jver} found", + "Нужна Java {r}+, найдена {j}. Возможны проблемы.": + "Need Java {r}+, found {j}. Issues possible.", + "Подтверждаю": "Confirm", + "Да": "Yes", + "Нет": "No", + + # --- моды (Modrinth) + "Моды…": "Mods…", + "Моды": "Mods", + "Поиск модов на Modrinth:": "Search mods on Modrinth:", + "Искать": "Search", + "Версия для поиска:": "Version for search:", + "загрузок": "downloads", + "Описание:": "Description:", + "Установленные моды": "Installed mods", + "Включить": "Enable", + "Выключить": "Disable", + "Папка mods": "Open mods folder", + "Установить Fabric API": "Install Fabric API", + "Мод установлен: ": "Mod installed: ", + "Мод отключён: ": "Mod disabled: ", + "Мод включён: ": "Mod enabled: ", + "Мод удалён: ": "Mod deleted: ", + "Моды для {v}": "Mods for {v}", + "Нет Fabric/Quilt-версии — API не ставится.": "No Fabric/Quilt version selected — API cannot be installed.", + "Ничего не найдено": "Nothing found", + "Идёт поиск…": "Searching…", + "нет совпадений": "no matches", + "Нужен Fabric или Quilt": "Fabric or Quilt required", + "Уже установлено": "Already installed", + + # --- модпаки + "Импорт модпака…": "Import modpack…", + "Модпаки": "Modpacks", + "Модпак установлен: ": "Modpack installed: ", + "Установка модпака: ": "Installing modpack: ", + "Установка: ": "Installing: ", + "Модпак": "Modpack", + "Ок": "OK", + + # --- история + "История и статистика…": "History & stats…", + "История и статистика": "History & stats", + "Всего времени: ": "Total playtime: ", + "Запусков: ": "Launches: ", + "Последние запуски": "Recent launches", + "Очистить историю": "Clear history", + "История очищена": "History cleared", + "вылетов": "crashes", + "вылет": "crash", + "шт.": "x", + + # --- окно игры / JVM-пресеты (профиль) + "Окно игры:": "Game window:", + "Ширина:": "Width:", + "Высота:": "Height:", + "Полный экран": "Fullscreen", + "0 или пусто = как в игре.": "0 or empty = game default.", + "Пресет JVM:": "JVM preset:", + "(нет)": "(none)", + "Производительность (1.16+)": "Performance (1.16+)", + "Старые версии (Java 8)": "Old versions (Java 8)", + "Скин-превью…": "Skin preview…", + + # --- настройки: прокси, масштаб, бэкап + "Прокси:": "Proxy:", + "Использовать прокси": "Use proxy", + "Тип:": "Type:", + "Хост:": "Host:", + "Порт:": "Port:", + "Логин:": "Login:", + "Пароль:": "Password:", + "Масштаб интерфейса:": "UI scale:", + "Применится сразу (шрифты).": "Applies immediately (fonts).", + "Авто-бэкап мира перед запуском": "Auto-backup world before launch", + "HTTP-прокси и SOCKS5. Пустой хост = выключен.": "HTTP proxy and SOCKS5. Empty host = off.", + "Проверить прокси": "Test proxy", + "Прокси работает": "Proxy works", + "Прокси не работает: ": "Proxy failed: ", + + # --- журнал: архив и поиск + "Сеанс (живой)": "Session (live)", + "Архив логов": "Log archive", + "Найти:": "Find:", + "Найдено: ": "Found: ", + "нет": "none", + "только ошибки": "errors only", + "строка": "lines", + + # --- скин + "Обновить": "Refresh", + "Своё фото…": "Own file…", + "Скин загружен": "Skin loaded", + "Скин не найден": "Skin not found", + "Скин-превью": "Skin preview", + + # --- ярлык / экспорт + "Создать ярлык версии…": "Create shortcut for version…", + "Ярлык создан: ": "Shortcut created: ", + "Ярлык": "Shortcut", + "Профиль не найден: ": "Profile not found: ", + "Не удалось создать ярлык": "Failed to create shortcut", + "Экспорт настроек…": "Export settings…", + "Импорт настроек…": "Import settings…", + "Настройки экспортированы: ": "Settings exported: ", + "Настройки импортированы": "Settings imported", + "Не удалось прочитать файл настроек": "Failed to read settings file", + + # --- сцены баннера + "Сцена баннера": "Banner scene", + "День": "Day", + "Ночь": "Night", + "Незер": "Nether", + "Пещера": "Cave", + "Случайно": "Random", + "Пейзаж Нижнего мира": "Nether landscape", + "Пещерный пейзаж": "Cave landscape", + "Сцена: ": "Scene: ", + + # --- трей + "Показать лаунчер": "Show launcher", + "Выход": "Quit", + "Игра запущена": "Game launched", + + # --- профили запуска + "Профили…": "Profiles…", + "Профили": "Profiles", + "Профиль": "Profile", + "Применить": "Apply", + "Сохранить текущие": "Save current", + "Новый профиль": "New profile", + "Изменить": "Edit", + "Без профиля": "No profile", + "Профиль применён: ": "Profile applied: ", + "Профиль удалён: ": "Profile removed: ", + "Имя:": "Name:", + "ОЗУ (МБ):": "RAM (MB):", + "Полный экран": "Fullscreen", + "Запомнить включённые моды": "Remember enabled mods", + "Список профилей пуст": "Profile list is empty", + "Выберите профиль": "Select a profile", + "Удалить профиль {p}?": "Remove profile {p}?", + + # --- миры и копии + "Миры и копии…": "Worlds & backups…", + "Миры": "Worlds", + "Копии": "Backups", + "Создать копию": "Create backup", + "Копия мира {w} создана: ": "Backup of {w} created: ", + "Удалить копию": "Delete backup", + "Удалить копию {b}?": "Delete backup {b}?", + "Открыть папку копий": "Open backups folder", + "Удалить мир": "Delete world", + "Удалить мир {w}? Это нельзя отменить.": + "Delete world {w}? This cannot be undone.", + "Мир удалён: ": "World deleted: ", + "Копия удалена: ": "Backup deleted: ", + "Список миров пуст": "No worlds found", + "Копий нет": "No backups", + "Выберите мир": "Select a world", + + # --- избранные версии + "★ Закрепить/убрать": "★ Pin/unpin", + "Только избранные": "Only pinned", + "★ — закрепить версию, двойной клик — выбрать": + "★ — pin version, double-click — select", + + # --- обновления модов + "Проверить обновления": "Check for updates", + "Проверка обновлений…": "Checking for updates…", + "Обновлений не найдено": "No updates found", + "Обновлений: ": "Updates: ", + "Обновить всё": "Update all", + "Обновить выбранное": "Update selected", + "Обновление: ": "Updating: ", + "Мод обновлён: ": "Mod updated: ", + "Не удалось обновить: ": "Failed to update: ", + + # --- разбор вылетов + "Причина: ": "Cause: ", + "Похоже, не хватило ОЗУ. Увеличьте её в профиле или выберите пресет.": + "Out of memory. Increase RAM in the profile or pick a preset.", + "Проблема с Java: ": "Java problem: ", + "Вероятная причина — мод {m}. Отключить его?": + "Likely cause — mod {m}. Disable it?", + "Мод отключён: ": "Mod disabled: ", + + # --- автообновление лаунчера + "Проверить обновление лаунчера…": "Check for launcher update…", + "Проверить обновление…": "Check for update…", + "Проверка обновления…": "Checking for update…", + "Обновление": "Update", + "Не удалось проверить обновления: ": "Failed to check for updates: ", + "У вас последняя версия": "You have the latest version", + "Новая версия {v} (у вас {c}).\n\n{notes}\n\nСкачать и установить?": + "New version {v} (you have {c}).\n\n{notes}\n\nDownload " + "and install?", + "Дождитесь окончания текущей операции": + "Wait for the current operation to finish", + "Скачивание обновления": "Downloading update", + "Не удалось запустить установщик: ": "Failed to start the installer: ", + "Запускается установщик обновления…": "Starting the update installer…", + "Обновление не установлено: ": "Update not installed: ", + "Отменено": "Cancelled", + + # --- мастер первоначальной настройки (первый запуск) + "Добро пожаловать в PixelLauncher!": "Welcome to PixelLauncher!", + "Первая настройка займёт минуту: язык, ник и память для игры.": + "First-run setup takes a minute: language, nickname and RAM.", + "Язык можно поменять позже в настройках.": + "You can change the language later in settings.", + "Шаг {n} из {m}": "Step {n} of {m}", + "Как тебя зовут?": "What's your nickname?", + "Ник игрока (виден в игре):": "Player nickname (visible in game):", + "Недопустимый ник: латиница, цифры и _, 3-16 символов": + "Invalid nickname: Latin letters, digits and _, 3-16 chars", + "Сколько памяти выделить игре?": "How much RAM should the game get?", + "Подобрать автоматически": "Choose automatically", + "{mb} МБ ({gb} ГБ)": "{mb} MB ({gb} GB)", + "Можно оставить авто — лаунчер подберёт значение при запуске.": + "You can keep auto — the launcher will pick a value at launch.", + "Всё готово!": "All set!", + "Ник: {nick}": "Nickname: {nick}", + "Язык: {lang}": "Language: {lang}", + "Память: {ram}": "RAM: {ram}", + "авто": "auto", + "Чтобы пройти настройку заново, удалите файл settings.json.": + "To run the setup again, delete the settings.json file.", + "Назад": "Back", + "Далее": "Next", + "Пропустить настройку": "Skip setup", + "Начать играть": "Start playing", + + # --- плагины и магазин + "Плагины…": "Plugins…", + "Магазин плагинов…": "Plugin store…", + "Плагины": "Plugins", + "Магазин плагинов": "Plugin store", + "Плагинов нет. Загрузите из магазина.": + "No plugins yet. Get some from the store.", + "Папка плагинов: ": "Plugin folder: ", + "Пересканировать": "Rescan", + "Удалить плагин {f}?": "Delete plugin {f}?", + "Открыть папку": "Open folder", + "Загрузка каталога…": "Loading catalog…", + "Каталог недоступен: ": "Catalog unavailable: ", + "Плагинов в каталоге: ": "Plugins in catalog: ", + "Установка…": "Installing…", + "Плагин установлен: ": "Plugin installed: ", + "Он уже загружен.": "It is already loaded.", + + # --- протокол pixellauncher:// + "Неизвестная ссылка: ": "Unknown link: ", + "Не удалось выполнить ссылку: ": "Failed to open link: ", + "Протокол: ": "Protocol: ", + "Действие плагина: ошибка: ": "Plugin action failed: ", +} + + +def set_language(lang): + global LANG + LANG = "en" if lang == "en" else "ru" + + +def T(s): + """Перевод строки; если перевода нет — исходная строка.""" + if LANG == "en": + return _TABLE.get(s, s) + return s diff --git a/launcher/java.py b/launcher/java.py new file mode 100644 index 0000000..5e97ee8 --- /dev/null +++ b/launcher/java.py @@ -0,0 +1,175 @@ +"""Поиск и выбор Java.""" + +import json +import os +import re +import shutil +import struct +import subprocess +import sys + +CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0 + + +def is64(): + return struct.calcsize("P") * 8 == 64 + + +def _exe(name="java"): + return name + (".exe" if os.name == "nt" else "") + + +def java_major(path): + """Версия Java в виде числа (8, 11, 17, 21...), 0 если не определилась.""" + try: + p = subprocess.run( + [path, "-version"], + capture_output=True, text=True, timeout=20, + creationflags=CREATE_NO_WINDOW, + ) + text = (p.stderr or "") + (p.stdout or "") + except Exception: + return 0 + m = re.search(r'version\s+"(\d+)(?:\.(\d+))?(?:_(\d+))?', text) + if not m: + return 0 + major = int(m.group(1)) + if major == 1: # старый формат 1.8.0_382 + return int(m.group(2) or 0) + return major + + +def _add(cands, home): + if not home: + return + exe = os.path.join(home, "bin", _exe()) + if os.path.isfile(exe) and exe not in cands: + cands.append(exe) + + +def find_javas(): + cands = [] + + # Переменные окружения + _add(cands, os.environ.get("JAVA_HOME")) + for k, v in os.environ.items(): + if k.upper().startswith("JAVA_HOME") and v: + _add(cands, v) + + # PATH + w = shutil.which(_exe()) + if w: + cands.append(w) + + if os.name == "nt": + pf = [ + r"C:\Program Files\Java", + r"C:\Program Files (x86)\Java", + r"C:\Program Files\Eclipse Adoptium", + r"C:\Program Files\AdoptOpenJDK", + r"C:\Program Files\Microsoft", + r"C:\Program Files\Amazon Corretto", + r"C:\Program Files\Zulu", + r"C:\Program Files\BellSoft", + r"C:\Program Files\GraalVM", + r"C:\Program Files\JetBrains", + ] + home_jdks = os.path.join(os.path.expanduser("~"), ".jdks") + pf.append(home_jdks) + for root in pf: + if not os.path.isdir(root): + continue + try: + for name in os.listdir(root): + _add(cands, os.path.join(root, name)) + except OSError: + pass + elif sys.platform == "darwin": + for root in ("/Library/Java/JavaVirtualMachines", + os.path.expanduser("~/Library/Java/JavaVirtualMachines")): + if not os.path.isdir(root): + continue + try: + for name in os.listdir(root): + _add(cands, os.path.join(root, name, "Contents", "Home")) + except OSError: + pass + else: + jvm_dir = "/usr/lib/jvm" + if os.path.isdir(jvm_dir): + try: + for name in os.listdir(jvm_dir): + _add(cands, os.path.join(jvm_dir, name)) + except OSError: + pass + + return cands + + +def pick_java(required=8, preferred="", cache_path=""): + """Выбирает Java: (путь, major, подходит_ли). + + Из подходящих (major >= required) берётся самая старая — максимальная + совместимость со старыми версиями Minecraft. + + cache_path — файл кэша с результатами определения версии. Пока файл + java.exe не менялся, `java -version` повторно не запускается. + """ + if preferred and os.path.isfile(preferred): + major = java_major(preferred) + return preferred, major, (major == 0 or major >= required) + + cache = {} + if cache_path: + try: + with open(cache_path, encoding="utf-8") as f: + cache = json.load(f) + if not isinstance(cache, dict): + cache = {} + except Exception: # noqa: BLE001 + cache = {} + + changed = False + cands = find_javas() + scored = [] + for exe in cands: + try: + mtime = int(os.path.getmtime(exe)) + except OSError: + continue + ent = cache.get(exe) + if isinstance(ent, list) and len(ent) == 2 and ent[1] == mtime: + major = int(ent[0]) # кэш валиден: файл не менялся + else: + major = java_major(exe) + cache[exe] = [major, mtime] + changed = True + if major > 0: + scored.append((exe, major)) + + # выбывшие установки — убрать из кэша + alive = set(cands) + for exe in [e for e in cache if e not in alive]: + del cache[exe] + changed = True + + if cache_path and changed: + try: + os.makedirs(os.path.dirname(cache_path), exist_ok=True) + with open(cache_path, "w", encoding="utf-8") as f: + json.dump(cache, f) + except OSError: + pass + + if not scored: + return "", 0, False + + suitable = [s for s in scored if s[1] >= required] + if suitable: + suitable.sort(key=lambda s: s[1]) + exe, major = suitable[0] + return exe, major, True + # Ничего подходящего — берём самую новую и предупредим + scored.sort(key=lambda s: s[1]) + exe, major = scored[-1] + return exe, major, False diff --git a/launcher/java_fetch.py b/launcher/java_fetch.py new file mode 100644 index 0000000..a6fe0cb --- /dev/null +++ b/launcher/java_fetch.py @@ -0,0 +1,198 @@ +"""Скачивание и установка Java с Eclipse Adoptium, если она не найдена. + +Ставит JRE в `.launcher/runtime/java-` и возвращает путь к java. +""" + +import os +import shutil +import sys +import tarfile +import zipfile + +from . import http +from .i18n import T + +API = ("https://api.adoptium.net/v3/binary/latest/{feature}/ga/" + "{platform}/{arch}/jre/hotspot/normal/eclipse") +LTS = (8, 11, 17, 21, 25) + + +def _platform(): + if sys.platform.startswith("win"): + return "windows" + if sys.platform == "darwin": + return "mac" + return "linux" + + +def _arch(): + try: + import platform + m = platform.machine().lower() + except Exception: # noqa: BLE001 + return "x64" + if m in ("arm64", "aarch64"): + return "aarch64" + if m in ("x86_64", "amd64"): + return "x64" + return "x86" + + +def _exe(): + return "java.exe" if os.name == "nt" else "java" + + +def pick_feature(required): + """Ближайшая LTS-версия не ниже требуемой.""" + for f in LTS: + if f >= required: + return f + return LTS[-1] + + +def runtime_dir(runtime_root, feature): + return os.path.join(runtime_root, "java-" + str(feature)) + + +def find_runtime_java(runtime_root, feature): + """Путь к bin/java установленной рантайм-Java, "" — не найдено.""" + base = runtime_dir(runtime_root, feature) + exe = _exe() + direct = os.path.join(base, "bin", exe) + if os.path.isfile(direct): + return direct + # архив Adoptium распаковывается во вложенную папку (jdk-21.0.x+y) + try: + for root, dirs, files in os.walk(base): + if root.count(os.sep) - base.count(os.sep) >= 3: + dirs[:] = [] + continue + if exe in files and os.path.basename(root) == "bin": + return os.path.join(root, exe) + except OSError: + pass + return "" + + +def find_installed(runtime_root, required): + """Лучшая уже скачанная Java не ниже required (путь, "" — нет). + + Название каталога (java-21) хранит major-версию — не запускаем + `java -version` повторно. + """ + best, best_major = "", 999 + try: + names = os.listdir(runtime_root) + except OSError: + return "" + for name in names: + if not name.startswith("java-"): + continue + try: + major = int(name.split("-", 1)[1]) + except ValueError: + continue + if not (required <= major < best_major): + continue + exe = find_runtime_java(runtime_root, major) + if exe: + best, best_major = exe, major + return best + + +def ensure_java(required, runtime_root, progress=None, log=None, cancel=None): + """Возвращает путь к Java не ниже required; при необходимости скачивает. + + progress(done_bytes, total_bytes|None) — прогресс скачивания архива. + """ + feature = pick_feature(required) + exe = find_runtime_java(runtime_root, feature) + if exe: + return exe + + url = API.format(feature=feature, platform=_platform(), arch=_arch()) + dest = runtime_dir(runtime_root, feature) + os.makedirs(dest, exist_ok=True) + archive = os.path.join(runtime_root, "java-%s.download" % feature) + if os.path.exists(archive): + try: + os.remove(archive) + except OSError: + pass + + if log: + log(T("Скачивание Java {v} (Adoptium)...").format(v=feature)) + total = http.head_size(url) + try: + http.download_file(url, archive, size=total, progress=progress, + cancel=cancel) + if log: + log(T("Распаковка Java...")) + _extract(archive, dest) + finally: + try: + os.remove(archive) + except OSError: + pass + + exe = find_runtime_java(runtime_root, feature) + if not exe: + raise RuntimeError(T("Не удалось распаковать Java")) + if log: + log(T("Java {v} установлена").format(v=feature), "ok") + return exe + + +def _safe_join(base, name): + """Путь внутри base; None — попытка выйти за пределы (path traversal).""" + parts = [p for p in name.replace("\\", "/").split("/") + if p not in ("", ".", "..")] + if not parts: + return None + return os.path.join(base, *parts) + + +def _extract(archive, dest): + # формат определяем по сигнатуре: имя файла служебное (java-XX.download) + with open(archive, "rb") as f: + magic = f.read(4) + if magic[:2] == b"PK": # ZIP + with zipfile.ZipFile(archive) as z: + for info in z.infolist(): + target = _safe_join(dest, info.filename) + if target is None: + continue + if info.is_dir(): + os.makedirs(target, exist_ok=True) + continue + os.makedirs(os.path.dirname(target), exist_ok=True) + with z.open(info) as src, open(target, "wb") as dst: + shutil.copyfileobj(src, dst) + else: + with tarfile.open(archive, "r:*") as t: + for m in t.getmembers(): + if not (m.isfile() or m.isdir()): + continue + target = _safe_join(dest, m.name) + if target is None: + continue + if m.isdir(): + os.makedirs(target, exist_ok=True) + continue + os.makedirs(os.path.dirname(target), exist_ok=True) + src = t.extractfile(m) + if src is None: + continue + with src, open(target, "wb") as dst: + shutil.copyfileobj(src, dst) + + # права на исполнение для содержимого bin/ (linux/mac) + if os.name != "nt": + for root, dirs, files in os.walk(dest): + if os.path.basename(root) != "bin": + continue + for f in files: + try: + os.chmod(os.path.join(root, f), 0o755) + except OSError: + pass diff --git a/launcher/launch.py b/launcher/launch.py new file mode 100644 index 0000000..9e5b202 --- /dev/null +++ b/launcher/launch.py @@ -0,0 +1,99 @@ +"""Запуск игры (offline-mode).""" + +import hashlib +import os +import subprocess +import threading +import time + +from .i18n import T + +CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0 + + +def offline_uuid(username): + """UUID, который Mojang генерирует для оффлайн-игроков.""" + h = hashlib.md5(("OfflinePlayer:" + username).encode("utf-8")).digest() + b = bytearray(h) + b[6] = (b[6] & 0x0F) | 0x30 # version 3 + b[8] = (b[8] & 0x3F) | 0x80 # RFC 4122 variant + return "".join(f"{x:02x}" for x in b) + + +def launch(game_dir, cmd, log=None, log_path=None, on_line=None, on_exit=None): + """Запускает процесс игры. Возвращает Popen. + + log_path — файл для полного вывода игры; + on_line — вызывается на каждую строку вывода (живой лог); + on_exit — вызывается после завершения процесса (после чтения вывода). + """ + if log: + log("Команда: " + " ".join(cmd)) + + stream = on_line is not None or log_path is not None + f = None + if log_path: + try: + os.makedirs(os.path.dirname(log_path), exist_ok=True) + f = open(log_path, "ab") + f.write(("\n=== PixelLauncher: " + + time.strftime("%Y-%m-%d %H:%M:%S") + " ===\n" + + "Команда: " + " ".join(cmd) + "\n").encode("utf-8", + "replace")) + except OSError: + f = None + + proc = subprocess.Popen( + cmd, + cwd=game_dir, + stdout=subprocess.PIPE if stream else subprocess.DEVNULL, + stderr=subprocess.STDOUT if stream else subprocess.DEVNULL, + text=True, encoding="utf-8", errors="replace", bufsize=1, + creationflags=CREATE_NO_WINDOW, + ) + + def _pump(): + """Читает вывод игры: пишет в файл и отдаёт строки построчно.""" + try: + for line in proc.stdout: + if f is not None: + try: + f.write(line.encode("utf-8", "replace")) + except ValueError: + pass + if on_line: + try: + on_line(line.rstrip("\r\n")) + except Exception: # noqa: BLE001 + pass + except (ValueError, OSError): + pass + finally: + if f is not None: + try: + f.close() + except OSError: + pass + + pump = None + if stream: + pump = threading.Thread(target=_pump, daemon=True) + pump.start() + elif f is not None: + f.close() + + def _wait(): + code = proc.wait() + if pump is not None: # дать выводу дочитаться + pump.join(timeout=10) + if on_exit: + try: + on_exit(code) + except Exception: # noqa: BLE001 + pass + + threading.Thread(target=_wait, daemon=True).start() + + if log: + log(T("Игра запущена, PID ") + str(proc.pid) + ". " + T("Приятной игры!")) + return proc diff --git a/launcher/manifest.py b/launcher/manifest.py new file mode 100644 index 0000000..003e8a9 --- /dev/null +++ b/launcher/manifest.py @@ -0,0 +1,111 @@ +"""Работа с манифестом версий Mojang (piston-meta).""" + +import json +import os +import time + +from . import http + +MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" +MANIFEST_MAX_AGE = 3600 # свежий кэш не перекачиваем (час) + +_memory_cache = None +_skip_ttl_once = False + + +def _cache_dir(game_dir): + d = os.path.join(game_dir, ".launcher") + os.makedirs(d, exist_ok=True) + return d + + +def invalidate(): + """Сбросить кэш: следующий get_manifest скачает манифест заново.""" + global _memory_cache, _skip_ttl_once + _memory_cache = None + _skip_ttl_once = True + + +def get_manifest(game_dir, net=True, max_age=MANIFEST_MAX_AGE): + """Возвращает манифест версий. + + Свежий кэш на диске используется сразу, без обращения к сети. + Сеть -> кэш на диске -> ошибка. + """ + global _memory_cache, _skip_ttl_once + if _memory_cache is not None: + return _memory_cache + + cache = os.path.join(_cache_dir(game_dir), "version_manifest.json") + force = _skip_ttl_once + _skip_ttl_once = False + + fresh = False + if os.path.isfile(cache): + try: + fresh = (time.time() - os.path.getmtime(cache)) < max_age + except OSError: + fresh = False + + if net and (force or not fresh): + try: + data = json.loads(http.get_bytes(MANIFEST_URL, timeout=20)) + with open(cache, "w", encoding="utf-8") as f: + json.dump(data, f) + _memory_cache = data + return data + except Exception: + pass + if os.path.isfile(cache): + with open(cache, encoding="utf-8") as f: + _memory_cache = json.load(f) + return _memory_cache + raise RuntimeError("Нет доступа к сети и нет кэша манифеста версий") + + +def version_url(manifest, vid): + for v in manifest.get("versions", []): + if v.get("id") == vid: + return v.get("url") + return None + + +def local_versions(game_dir): + """Идентификаторы версий, установленных локально (Fabric, Quilt и т.п.).""" + root = os.path.join(game_dir, "versions") + out = [] + try: + for name in sorted(os.listdir(root)): + if os.path.isfile(os.path.join(root, name, name + ".json")): + out.append(name) + except OSError: + pass + return out + + +def fetch_version_json(game_dir, vid, net=True, max_age=21600): + """JSON конкретной версии: свежий кэш -> сеть -> versions/{id}/{id}.json.""" + path = os.path.join(game_dir, "versions", vid, vid + ".json") + fresh = False + if os.path.isfile(path): + try: + fresh = (time.time() - os.path.getmtime(path)) < max_age + except OSError: + fresh = False + if net and not fresh: + try: + manifest = get_manifest(game_dir, net=True) + url = version_url(manifest, vid) + if not url: + raise RuntimeError(f"Версия {vid} не найдена в манифесте") + raw = http.get_bytes(url, timeout=20) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(raw) + return json.loads(raw) + except Exception: + pass + if os.path.isfile(path): + with open(path, encoding="utf-8") as f: + return json.load(f) + raise RuntimeError(f"Не удалось получить JSON версии {vid}") diff --git a/launcher/modrinth.py b/launcher/modrinth.py new file mode 100644 index 0000000..3a8c139 --- /dev/null +++ b/launcher/modrinth.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +"""Клиент API Modrinth (https://api.modrinth.com/v2) — без ключей и библиотек. + +Поиск модов, получение версий проекта и скачивание файлов. +Списки игнорируются через обычный http (умеет прокси и зеркала). +""" + +import json +import time +import urllib.parse + +from . import http + +BASE = "https://api.modrinth.com/v2" + +_cache = {} # url -> (время, данные) + +# slugs официальных проектов +FABRIC_API_SLUG = "fabric-api" +QUILTED_API_SLUG = "quilted-fabric-api" + + +def _get_json(url, ttl=300): + hit = _cache.get(url) + if hit and time.time() - hit[0] < ttl: + return hit[1] + data = json.loads(http.get_bytes(url, timeout=30).decode("utf-8")) + _cache[url] = (time.time(), data) + return data + + +def search(query, game_version="", loader="", limit=20, offset=0): + """Поиск модов. Возвращает (список, всего_найдено).""" + facets = [["project_type:mod"]] + if game_version: + facets.append(["versions:" + game_version]) + if loader: + facets.append(["categories:" + loader]) + params = { + "query": query or "", + "facets": json.dumps(facets, separators=(",", ":")), + "limit": limit, + "offset": offset, + "index": "relevance" if query else "downloads", + } + url = BASE + "/search?" + urllib.parse.urlencode(params) + data = _get_json(url, ttl=300) + out = [] + for h in data.get("hits") or []: + out.append({ + "id": h.get("project_id") or "", + "title": h.get("title") or h.get("slug") or "?", + "slug": h.get("slug") or "", + "desc": (h.get("description") or "").strip(), + "downloads": int(h.get("downloads") or 0), + "follows": int(h.get("follows") or 0), + "icon": h.get("icon_url") or "", + "author": h.get("author") or "", + "categories": h.get("categories") or [], + }) + return out, int(data.get("total_hits") or 0) + + +def project(pid): + """Карточка проекта: заголовок, описание.""" + d = _get_json(BASE + "/project/" + str(pid), ttl=600) + return { + "id": d.get("id") or "", + "title": d.get("title") or "?", + "desc": (d.get("description") or "").strip(), + "body": d.get("body") or "", + "downloads": int(d.get("downloads") or 0), + "icon": d.get("icon_url") or "", + } + + +def versions(pid, game_version="", loader=""): + """Версии проекта под версию игры/загрузчик (свежие первыми).""" + url = BASE + "/project/" + str(pid) + "/version" + qs = [] + if game_version: + qs.append("game_versions=" + urllib.parse.quote( + json.dumps([game_version]))) + if loader: + qs.append("loaders=" + urllib.parse.quote(json.dumps([loader]))) + if qs: + url += "?" + "&".join(qs) + raw = _get_json(url, ttl=300) + out = [] + for v in raw if isinstance(raw, list) else []: + files = v.get("files") or [] + if not files: + continue + primary = next((f for f in files if f.get("primary")), files[0]) + hashes = primary.get("hashes") or {} + out.append({ + "id": v.get("id") or "", + "name": (v.get("name") or v.get("version_number") or "?").strip(), + "number": (v.get("version_number") or "").strip(), + "date": (v.get("date_published") or "")[:10], + "url": primary.get("url") or "", + "filename": primary.get("filename") or "", + "size": int(primary.get("size") or 0), + "sha1": hashes.get("sha1") or "", + }) + return out + + +def download_mod(file_url, dest, size=0, progress=None, cancel=None): + """Скачивает файл мода в dest. True при успехе.""" + return http.download_file(file_url, dest, size=size, + progress=progress, cancel=cancel) + + +def lookup_files(hashes, algorithm="sha1", batch=100): + """Определяет версии Modrinth по хэшам файлов. + + POST /version_files: {хэш: версия, в которой лежит этот файл}. + """ + out = {} + lst = [h for h in hashes if h] + for i in range(0, len(lst), batch): + data = http.post_json(BASE + "/version_files", + {"hashes": lst[i:i + batch], + "algorithm": algorithm}, timeout=30) + if isinstance(data, dict): + out.update({k: v for k, v in data.items() if isinstance(v, dict)}) + return out + + +def _find_installed(mods_dir, prefixes): + """Есть ли уже мод с одним из префиксов в имени файла.""" + import os + if not os.path.isdir(mods_dir): + return False + low = [p.lower() for p in prefixes] + for name in os.listdir(mods_dir): + lname = name.lower() + if lname.endswith(".jar") and any(lname.startswith(p) for p in low): + return True + return False + + +def ensure_loader_api(game_dir, game_version, loader, log=None): + """Ставит Fabric API / Quilted Fabric API, если модов нет. + + Возвращает число скачанных файлов (0 — уже стоит или недоступно). + """ + import os + if loader not in ("fabric", "quilt"): + return 0 + mods_dir = os.path.join(game_dir, "mods") + os.makedirs(mods_dir, exist_ok=True) + slug = FABRIC_API_SLUG if loader == "fabric" else QUILTED_API_SLUG + prefixes = ["fabric-api"] if loader == "fabric" else ["quilted-fabric-api"] + if _find_installed(mods_dir, prefixes): + return 0 + if log: + log(f"Modrinth: ставлю {'Fabric API' if loader == 'fabric' else 'Quilted Fabric API'}…") + try: + pid = slug # API принимает и slug + vs = versions(pid, game_version, loader) + if not vs: + if log: + log(f"Modrinth: нет сборки {slug} для {game_version}/{loader}") + return 0 + v = vs[0] + dest = os.path.join(mods_dir, v["filename"]) + ok = download_mod(v["url"], dest, size=v["size"]) + if ok and log: + log(f"Modrinth: {v['filename']} установлен") + return 1 if ok else 0 + except Exception as e: # noqa: BLE001 — сеть/JSON не должны ронять лаунчер + if log: + log(f"Modrinth: не удалось поставить API ({e})") + return 0 diff --git a/launcher/mods.py b/launcher/mods.py new file mode 100644 index 0000000..ab46465 --- /dev/null +++ b/launcher/mods.py @@ -0,0 +1,198 @@ +"""Установка модлоадеров Fabric и Quilt. + +Ставит JSON профиля версии (с inheritsFrom на игровую версию) — лаунчер уже +умеет собирать classpath для таких версий, а файлы лоадера докачиваются +обычным механизмом при первом запуске. +""" + +import json +import os + +from . import http + +LOADERS = ("fabric", "quilt") +META = { + "fabric": "https://meta.fabricmc.net/v2", + "quilt": "https://meta.quiltmc.org/v3", +} + +_cache = {} # loader -> {"game": [(id, stable)], "loader": [ver]} + + +def _base(loader): + return META.get(loader) or META["fabric"] + + +def game_versions(loader): + """[(id, stable)] игровых версий, поддерживаемых лоадером (свежие первые).""" + if loader not in _cache: + data = json.loads(http.get_bytes(_base(loader) + "/versions/game", + timeout=20)) + games, loads = [], [] + for v in data or []: + vid = v.get("version") + if vid: + games.append((vid, bool(v.get("stable")))) + _cache[loader] = {"game": games, "loader": loads} + return _cache[loader]["game"] + + +def loader_versions(loader): + """Список версий лоадера (свежая первая).""" + if loader not in _cache: + game_versions(loader) # прогревает кэш + if not _cache[loader]["loader"]: + data = json.loads(http.get_bytes(_base(loader) + "/versions/loader", + timeout=20)) + _cache[loader]["loader"] = [v["version"] for v in (data or []) + if v.get("version")] + return _cache[loader]["loader"] + + +def install(game_dir, loader, game_version, loader_version=None): + """Скачивает JSON профиля лоадера. Возвращает id установленной версии.""" + base = _base(loader) + if not loader_version: + vs = loader_versions(loader) + if not vs: + raise RuntimeError(f"нет версий лоадера {loader}") + loader_version = vs[0] + url = (f"{base}/versions/loader/{game_version}/{loader_version}/" + "profile/json") + raw = http.get_bytes(url, timeout=30) + data = json.loads(raw) + vid = data.get("id") or f"{loader}-loader-{loader_version}-{game_version}" + path = os.path.join(game_dir, "versions", vid, vid + ".json") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(raw) + return vid + + +# ------------------------------------------------------- менеджер модов (UI) + +def mods_dir(game_dir): + d = os.path.join(game_dir, "mods") + os.makedirs(d, exist_ok=True) + return d + + +def list_mods(game_dir): + """Файлы в mods/: [{file, name, enabled, size}] (имя без .jar/.disabled).""" + out = [] + d = mods_dir(game_dir) + for name in sorted(os.listdir(d), key=str.lower): + low = name.lower() + if low.endswith(".jar.disabled"): + enabled, base = False, name[:-len(".disabled")] + elif low.endswith(".jar"): + enabled, base = True, name[:-4] + else: + continue + try: + size = os.path.getsize(os.path.join(d, name)) + except OSError: + size = 0 + out.append({"file": name, "name": base, "enabled": enabled, + "size": size}) + return out + + +def set_enabled(game_dir, filename, on): + """Вкл/выкл мода переименованием (.jar ↔ .jar.disabled). Новое имя/None.""" + d = mods_dir(game_dir) + low = filename.lower() + if on: + if not low.endswith(".jar.disabled"): + return None + dst_name = filename[:-len(".disabled")] + else: + if not low.endswith(".jar"): + return None + dst_name = filename + ".disabled" + if os.path.exists(os.path.join(d, dst_name)): + return None + os.rename(os.path.join(d, filename), os.path.join(d, dst_name)) + return dst_name + + +def remove_mod(game_dir, filename): + """Удаляет файл мода. True при успехе.""" + try: + os.remove(os.path.join(mods_dir(game_dir), filename)) + return True + except OSError: + return False + + +def fmt_size(n): + """3145728 → «3,0 МБ».""" + n = float(n or 0) + for unit in ("Б", "КБ", "МБ", "ГБ"): + if n < 1024 or unit == "ГБ": + return f"{n:.1f} {unit}".replace(".", ",") if unit != "Б" \ + else f"{int(n)} {unit}" + n /= 1024 + return f"{int(n)} Б" + + +# ------------------------------------------------------- обновления модов + +def check_updates(game_dir, game_version, loader="", log=None): + """Сверяет включённые моды с Modrinth по SHA-1. + + Возвращает список обновлений: + [{file, name, cur, new_ver, new_file, url, size, sha1, project_id}]. + """ + from . import modrinth + enabled = [m for m in list_mods(game_dir) if m["enabled"]] + if not enabled: + return [] + if log: + log(f"Проверка {len(enabled)} модов по хэшу…") + hashes = {} + for m in enabled: + try: + h = http.sha1_file(os.path.join(mods_dir(game_dir), m["file"])) + except OSError: + continue + if h: + hashes[h] = m + if not hashes: + return [] + found = modrinth.lookup_files(list(hashes), "sha1") + out = [] + ver_cache = {} + for h, ver in found.items(): + m = hashes.get(h) + pid = ver.get("project_id") or "" + if not m or not pid: + continue + if pid not in ver_cache: + try: + ver_cache[pid] = modrinth.versions(pid, game_version, loader) + except Exception: # noqa: BLE001 + ver_cache[pid] = [] + vs = ver_cache[pid] + # свежая версия первая: если её хэш совпадает с файлом — мы актуальны + if not vs or not vs[0]["sha1"] or vs[0]["sha1"] == h: + continue + nv = vs[0] + out.append({"file": m["file"], "name": m["name"], + "cur": (ver.get("version_number") or "").strip(), + "new_ver": nv["name"], "new_file": nv["filename"], + "url": nv["url"], "size": nv["size"], + "sha1": nv["sha1"], "project_id": pid}) + out.sort(key=lambda u: u["name"].lower()) + return out + + +def apply_update(game_dir, u): + """Скачивает новую версию мода и отключает старый файл. True при успехе.""" + dest = os.path.join(mods_dir(game_dir), u["new_file"]) + ok = http.download_file(u["url"], dest, size=u["size"]) + if not ok: + return False + if u["file"] != u["new_file"]: + set_enabled(game_dir, u["file"], False) + return True diff --git a/launcher/mrpack.py b/launcher/mrpack.py new file mode 100644 index 0000000..c7448dc --- /dev/null +++ b/launcher/mrpack.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""Импорт модпаков .mrpack (формат Modrinth) и каталог модпаков. + +mrpack — zip с modrinth.index.json: индекс описывает версию Minecraft, +загрузчик (fabric/quilt/forge/neoforge) и список файлов для скачивания; +плюс каталоги overrides/ (client-overrides/) — готовые файлы папки игры. +""" + +import json +import os +import zipfile + +from . import forge, http, mods +from .java_fetch import _safe_join + +PACKS_BASE = "https://pixellauncher.ru/packs" +CATALOG_URL = PACKS_BASE + "/catalog.json" + + +def fetch_catalog(timeout=15): + """Каталог модпаков с сайта: [{title, desc, url, mc, loader, size}, ...].""" + raw = http.get_bytes(CATALOG_URL, timeout=timeout) + data = json.loads(raw.decode("utf-8")) + if not isinstance(data, list): + raise ValueError("пустой каталог модпаков") + return [x for x in data + if isinstance(x, dict) and isinstance(x.get("url"), str) + and x["url"]] + + +def pack_url(entry): + """Абсолютный URL пака: относительные пути тянутся с сайта.""" + u = str(entry.get("url") or "").strip() + if u.startswith(("http://", "https://")): + return u + return PACKS_BASE + "/" + u.lstrip("/") + + +def read_index(path): + """Читает индекс пака. Возвращает (индекс, mc_ver, loader, loader_ver).""" + with zipfile.ZipFile(path) as z: + with z.open("modrinth.index.json") as f: + data = json.loads(f.read().decode("utf-8")) + deps = data.get("dependencies") or {} + mc = deps.get("minecraft") or "" + loader, loader_ver = "", "" + if deps.get("fabric-loader"): + loader, loader_ver = "fabric", deps["fabric-loader"] + elif deps.get("quilt-loader"): + loader, loader_ver = "quilt", deps["quilt-loader"] + elif deps.get("neoforge"): + loader, loader_ver = "neoforge", deps["neoforge"] + elif deps.get("forge"): + loader, loader_ver = "forge", deps["forge"] + if not mc: + raise RuntimeError("в паке не указана версия Minecraft") + return data, mc, loader, loader_ver + + +def _files_from_index(index): + """[(путь_относительно_игры, url, filename, size)] — клиентские файлы.""" + out = [] + for entry in index.get("files") or []: + env = entry.get("env") or {} + if env.get("client") == "unsupported": + continue + downloads = entry.get("downloads") or [] + if not downloads: + continue + primary = next((d for d in downloads if d.get("primary")), downloads[0]) + url = primary.get("url") or "" + if not url: + continue + rel = (entry.get("path") or "").replace("\\", "/") + if not rel or rel.endswith("/"): + continue + out.append((rel, url, primary.get("filename") or rel.rsplit("/", 1)[-1], + int(primary.get("size") or entry.get("fileSize") or 0))) + return out + + +def install(game_dir, pack_path, log=None, progress=None, cancel=None): + """Ставит пак в game_dir. Возвращает (id_версии, скачано_файлов). + + progress(fraction) и cancel — как у http.download_file. + """ + if log: + log(f"Пак: читаю {os.path.basename(pack_path)}…") + index, mc, loader, loader_ver = read_index(pack_path) + + files = _files_from_index(index) + total = sum(max(1, f[3]) for f in files) or 1 + done = 0 + + # лоадер + vid = None + if loader: + if log: + log(f"Пак: Minecraft {mc}, {loader} {loader_ver}") + if loader in ("fabric", "quilt"): + vid = mods.install(game_dir, loader, mc, loader_version=loader_ver) + else: # forge / neoforge — официальным инсталлятором + vid = forge.install(game_dir, loader, mc, + loader_version=loader_ver or None, log=log) + done += max(1, total // (len(files) + 2)) + + # скачивание файлов + got = 0 + for rel, url, filename, size in files: + if cancel is not None and cancel.is_set(): + raise http.Cancelled + dest = _safe_join(game_dir, rel) + os.makedirs(os.path.dirname(dest), exist_ok=True) + if log: + log(f"Пак: {rel}") + if http.download_file(url, dest, size=size, cancel=cancel): + got += 1 + done += max(1, size or 1) + if progress: + progress(min(0.95, done / total)) + + # overrides + ov = 0 + with zipfile.ZipFile(pack_path) as z: + for name in z.namelist(): + target = None + for prefix in ("overrides/", "client-overrides/"): + if name.startswith(prefix) and not name.endswith("/"): + target = name[len(prefix):] + break + if not target: + continue + dest = _safe_join(game_dir, target) + os.makedirs(os.path.dirname(dest), exist_ok=True) + with z.open(name) as src, open(dest, "wb") as out: + out.write(src.read()) + ov += 1 + if log and ov: + log(f"Пак: распаковано {ov} файлов overrides") + + if progress: + progress(1.0) + if log: + log(f"Пак: готово — {got} файл(ов) скачано") + return vid, got diff --git a/launcher/onboarding.py b/launcher/onboarding.py new file mode 100644 index 0000000..af89574 --- /dev/null +++ b/launcher/onboarding.py @@ -0,0 +1,305 @@ +# -*- coding: utf-8 -*- +"""Мастер первоначальной настройки — показывается при первом запуске. + +Окно живёт до построения главного окна (см. ui.run): пользователь выбирает +язык, ник и объём ОЗУ. Результат пишется в settings, флаг onboarding_done +не даёт мастеру появиться снова; апгрейд со старой версии считается уже +настроенным (см. config.load_settings). +""" + +import getpass +import re +import tkinter as tk + +from . import i18n +from .i18n import T + +NICK_RE = re.compile(r"[A-Za-z0-9_]{3,16}") + + +def total_ram_mb(): + """ОЗУ системы в МБ (Windows — GlobalMemoryStatusEx, иначе 4096).""" + try: + import ctypes + + class _MemStatus(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("ullAvailExtendedVirtual", ctypes.c_ulonglong), + ] + + st = _MemStatus() + st.dwLength = ctypes.sizeof(_MemStatus) + if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(st)): + return max(1024, st.ullTotalPhys // (1024 * 1024)) + except Exception: # noqa: BLE001 + pass + return 4096 + + +def default_ram_mb(): + """Разумный дефолт: половина ОЗУ, в пределах 2-8 ГБ, кратно 512 МБ.""" + mb = (total_ram_mb() // 2) // 512 * 512 + return max(2048, min(8192, mb)) + + +def _suggest_nick(): + """Ник по умолчанию: имя пользователя Windows, если оно допустимо.""" + try: + user = getpass.getuser() + except Exception: # noqa: BLE001 + user = "" + return user if NICK_RE.fullmatch(user) else "Player" + + +class Wizard: + """Окно мастера: язык -> ник -> ОЗУ -> готово.""" + + def __init__(self, root, settings): + # палитру берём из ui (лениво: ui импортирует этот модуль в run()) + from .ui import (BG, DIM, ERR, FAINT, GREEN, GREEN_HI, INPUT, + INPUT_BD, PANEL, TEXT) + + self.pal = dict(BG=BG, DIM=DIM, ERR=ERR, FAINT=FAINT, GREEN=GREEN, + GREEN_HI=GREEN_HI, INPUT=INPUT, INPUT_BD=INPUT_BD, + PANEL=PANEL, TEXT=TEXT) + self.root = root + self.settings = settings + self.step = 0 + self.result = None # "done" | "skip" + + root.title("PixelLauncher") + root.configure(bg=BG) + root.geometry("560x430") + root.resizable(False, False) + root.protocol("WM_DELETE_WINDOW", self.skip) + + self.var_lang = tk.StringVar( + value="en" if settings.get("lang") == "en" else "ru") + self.var_nick = tk.StringVar(value=_suggest_nick()) + self.var_auto = tk.BooleanVar(value=True) + self.var_ram = tk.IntVar(value=default_ram_mb()) + self.var_err = tk.StringVar(value="") + + self.lbl_ram = None + self.scale_ram = None + self._goto(0) + self._center() + + # ------------------------------------------------------------ каркас + def _goto(self, i): + """Показать шаг i (окно перерисовывается целиком — тексты живые).""" + self.step = i + for w in self.root.winfo_children(): + w.destroy() + p = self.pal + + head = tk.Frame(self.root, bg=p["BG"]) + head.pack(fill="x", padx=36, pady=(26, 0)) + tk.Label(head, text="PixelLauncher", bg=p["BG"], fg=p["GREEN"], + font=("Consolas", 13, "bold")).pack(side="left") + tk.Label(head, text=T("Шаг {n} из {m}").format(n=i + 1, m=4), + bg=p["BG"], fg=p["DIM"], + font=("Segoe UI", 9)).pack(side="right") + + body = tk.Frame(self.root, bg=p["BG"]) + body.pack(fill="both", expand=True, padx=36, pady=18) + (self._step0 if i == 0 else self._step1 if i == 1 + else self._step2 if i == 2 else self._step3)(body) + + foot = tk.Frame(self.root, bg=p["BG"]) + foot.pack(fill="x", padx=36, pady=(0, 24)) + if i > 0: + self._btn(foot, T("Назад"), lambda: self._goto(i - 1)) + self._btn(foot, T("Пропустить настройку"), self.skip, dim=True) + label = T("Начать играть") if i == 3 else T("Далее") + self._btn(foot, label, self._next, accent=True, right=True) + + def _btn(self, parent, text, cmd, accent=False, dim=False, right=False): + p = self.pal + bg = p["GREEN"] if accent else (p["BG"] if dim else p["PANEL"]) + fg = "#0e1a08" if accent else (p["DIM"] if dim else p["TEXT"]) + b = tk.Button(parent, text=text, command=cmd, bg=bg, fg=fg, + activebackground=p["GREEN_HI"] if accent else bg, + activeforeground=fg, relief="flat", cursor="hand2", + font=("Segoe UI", 10, "bold" if accent else "normal"), + padx=16, pady=6) + b.pack(side="right" if right else "left", + padx=(10, 0) if right else (0, 10)) + return b + + def _center(self): + self.root.update_idletasks() + w, h = 560, 430 + x = (self.root.winfo_screenwidth() - w) // 2 + y = (self.root.winfo_screenheight() - h) // 2 - 30 + self.root.geometry("%dx%d+%d+%d" % (w, h, max(0, x), max(0, y))) + + def _title(self, parent, text): + p = self.pal + tk.Label(parent, text=text, bg=p["BG"], fg=p["TEXT"], + font=("Segoe UI", 15, "bold")).pack(anchor="w") + + # ------------------------------------------------------------ шаги + def _step0(self, body): + """Язык.""" + p = self.pal + self._title(body, T("Добро пожаловать в PixelLauncher!")) + tk.Label(body, wraplength=470, justify="left", bg=p["BG"], + fg=p["DIM"], font=("Segoe UI", 10), + text=T("Первая настройка займёт минуту: язык, ник и " + "память для игры.")).pack(anchor="w", pady=(10, 18)) + row = tk.Frame(body, bg=p["BG"]) + row.pack(anchor="w") + for code, caption in (("ru", "🇷🇺 Русский"), ("en", "🇬🇧 English")): + chosen = self.var_lang.get() == code + b = tk.Button(row, text=caption, bg=p["GREEN"] if chosen + else p["PANEL"], fg="#0e1a08" if chosen else p["TEXT"], + activebackground=p["GREEN_HI"], relief="flat", + cursor="hand2", font=("Segoe UI", 11, "bold"), + padx=22, pady=10, + command=lambda c=code: self._set_lang(c)) + b.pack(side="left", padx=(0, 12)) + tk.Label(body, wraplength=470, justify="left", bg=p["BG"], + fg=p["FAINT"], font=("Segoe UI", 9), + text=T("Язык можно поменять позже в настройках.") + ).pack(anchor="w", pady=(18, 0)) + + def _set_lang(self, code): + self.var_lang.set(code) + i18n.set_language(code) + self.settings["lang"] = code # сохранится даже при пропуске + self._goto(self.step) + + def _step1(self, body): + """Ник.""" + p = self.pal + self._title(body, T("Как тебя зовут?")) + tk.Label(body, bg=p["BG"], fg=p["DIM"], font=("Segoe UI", 10), + text=T("Ник игрока (виден в игре):") + ).pack(anchor="w", pady=(12, 6)) + e = tk.Entry(body, textvariable=self.var_nick, bg=p["INPUT"], + fg=p["TEXT"], insertbackground=p["TEXT"], + relief="flat", font=("Segoe UI", 13), width=24) + e.pack(anchor="w", ipady=7) + e.focus_set() + e.bind("", lambda ev: self._next()) + tk.Label(body, textvariable=self.var_err, bg=p["BG"], fg=p["ERR"], + font=("Segoe UI", 9)).pack(anchor="w", pady=(8, 0)) + tk.Label(body, wraplength=470, justify="left", bg=p["BG"], + fg=p["DIM"], font=("Segoe UI", 9), + text=T("Ник: 3-16 символов, латиница, цифры и _") + ).pack(anchor="w", pady=(4, 0)) + + def _step2(self, body): + """ОЗУ.""" + p = self.pal + self._title(body, T("Сколько памяти выделить игре?")) + auto = tk.Checkbutton(body, text=T("Подобрать автоматически"), + variable=self.var_auto, bg=p["BG"], fg=p["TEXT"], + selectcolor=p["INPUT"], activebackground=p["BG"], + activeforeground=p["TEXT"], relief="flat", + font=("Segoe UI", 10), cursor="hand2", + command=self._sync_ram) + auto.pack(anchor="w", pady=(14, 8)) + self.scale_ram = tk.Scale(body, from_=1024, to=16384, resolution=512, + orient="horizontal", showvalue=0, + bg=p["BG"], fg=p["TEXT"], troughcolor=p["INPUT"], + highlightthickness=0, length=440, + sliderrelief="flat", sliderlength=18, + command=self._on_ram) + self.scale_ram.set(self.var_ram.get()) + self.scale_ram.pack(anchor="w") + self.lbl_ram = tk.Label(body, text=self._ram_text(), bg=p["BG"], + fg=p["GREEN"], font=("Segoe UI", 11, "bold")) + self.lbl_ram.pack(anchor="w", pady=(4, 6)) + tk.Label(body, wraplength=470, justify="left", bg=p["BG"], + fg=p["DIM"], font=("Segoe UI", 9), + text=T("Можно оставить авто — лаунчер подберёт значение " + "при запуске.")).pack(anchor="w") + self._sync_ram() + + def _on_ram(self, value): + try: + self.var_ram.set(int(float(value))) + except (TypeError, ValueError): + return + if self.lbl_ram is not None: + self.lbl_ram.config(text=self._ram_text()) + + def _ram_text(self): + mb = int(self.var_ram.get()) + return T("{mb} МБ ({gb} ГБ)").format(mb=mb, gb=int(round(mb / 1024))) + + def _sync_ram(self): + """Галка «авто» включает/выключает ползунок.""" + if self.scale_ram is not None: + self.scale_ram.config( + state="disabled" if self.var_auto.get() else "normal") + + def _step3(self, body): + """Готово.""" + p = self.pal + self._title(body, T("Всё готово!")) + lang = T("English") if self.var_lang.get() == "en" else "Русский" + ram = T("авто") if self.var_auto.get() else self._ram_text() + info = "\n".join([T("Ник: {nick}").format( + nick=self.var_nick.get().strip() or "Player"), + T("Язык: {lang}").format(lang=lang), + T("Память: {ram}").format(ram=ram)]) + tk.Label(body, justify="left", anchor="w", bg=p["PANEL"], fg=p["TEXT"], + font=("Segoe UI", 11), padx=18, pady=14, text=info + ).pack(anchor="w", pady=(14, 12)) + tk.Label(body, wraplength=470, justify="left", bg=p["BG"], + fg=p["DIM"], font=("Segoe UI", 9), + text=T("Чтобы пройти настройку заново, удалите файл " + "settings.json.")).pack(anchor="w") + + # ------------------------------------------------------------ действия + def _next(self): + if self.step == 1 and not NICK_RE.fullmatch( + self.var_nick.get().strip()): + self.var_err.set(T("Недопустимый ник: латиница, цифры и _, " + "3-16 символов")) + return + if self.step == 3: + self.finish() + return + self._goto(self.step + 1) + + def finish(self): + """Применить выбор: язык, ник, ОЗУ — и закрыть мастер.""" + nick = self.var_nick.get().strip() + s = self.settings + s["lang"] = "en" if self.var_lang.get() == "en" else "ru" + i18n.set_language(s["lang"]) + s["username"] = nick + s["accounts"] = [nick] + s["active_account"] = nick + s["ram"] = int(self.var_ram.get()) + s["auto_ram"] = bool(self.var_auto.get()) + s["onboarding_done"] = True + self.result = "done" + self.root.destroy() + + def skip(self): + """Настроить позже: только зафиксировать, что мастер показан.""" + self.settings["onboarding_done"] = True + self.result = "skip" + self.root.destroy() + + +def run(settings, _auto_close_ms=None): + """Показать мастер и дождаться закрытия. settings меняется на месте.""" + root = tk.Tk() + Wizard(root, settings) + if _auto_close_ms: # тестовый режим: просто мигнуть окном + root.after(_auto_close_ms, root.destroy) + root.mainloop() diff --git a/launcher/plugins.py b/launcher/plugins.py new file mode 100644 index 0000000..33e7594 --- /dev/null +++ b/launcher/plugins.py @@ -0,0 +1,390 @@ +"""Плагины PixelLauncher: загрузка .py и событийная модель. + +Формат файла (точно совпадает с верстаком на pixellauncher.ru): + + # Плагин для PixelLauncher. + PLUGIN_NAME = "Мой плагин" + PLUGIN_VERSION = "1.0" + PLUGIN_AUTHOR = "Nik" + PLUGIN_DESCRIPTION = ( + "Что делает." + ) + + def on_load(api): + api.log("привет") + + def on_game_exit(profile_name, crashed, exit_code): + ... + + api.on("game_exit", on_game_exit) + api.add_action("Моя кнопка", action) + + def on_unload(): # необязательно + ... + +События: + launcher_ready() + before_launch(profile_name, version) + after_launch(profile_name, version, pid) + game_exit(profile_name, crashed, exit_code) + profile_switched(profile_name) + account_switched(username) + launcher_closing() + +Плагины лежат в папке plugins рядом с main.py (APP_DIR/plugins); +json-хранилище каждого плагина — в plugins/storage/<файл>.py.json. +""" + +import importlib.util +import json +import os +import re +import threading +import traceback + +from . import config + +PLUGIN_DIR = os.path.join(config.APP_DIR, "plugins") +STORAGE_DIR = os.path.join(PLUGIN_DIR, "storage") + +# каталог плагинов на сайте: магазин и pixellauncher://plugin/<файл> +STORE_BASE = "https://pixellauncher.ru/plugins" +CATALOG_URL = STORE_BASE + "/catalog.json" + +EVENTS = ("launcher_ready", "before_launch", "after_launch", "game_exit", + "profile_switched", "account_switched", "launcher_closing") + +# --- привязка к UI (App). Без неё api.log печатает в stdout, +# --- а окна не показываются (удобно для тестов). +_hooks = {"log": None, "message": None, "ask_text": None, + "settings": None, "game_dir": None, "game_running": None} +_hooks_lock = threading.Lock() + + +def bind_ui(log=None, message=None, ask_text=None, settings=None, + game_dir=None, game_running=None): + """Подключает обработчики UI. Вызывается из App один раз.""" + with _hooks_lock: + for k, v in (("log", log), ("message", message), + ("ask_text", ask_text), ("settings", settings), + ("game_dir", game_dir), + ("game_running", game_running)): + if v is not None: + _hooks[k] = v + + +def _hook(name, *args): + fn = _hooks.get(name) + if fn is None: + return None + try: + return fn(*args) + except Exception: # noqa: BLE001 + return None + + +def _call_log(msg): + if _hook("log", str(msg)) is None: + try: + print(msg) + except Exception: # noqa: BLE001 + pass + + +def _ui_message(level, title, text): + if _hooks.get("message") is None: + _call_log("%s: %s" % (title, text)) + return + _hook("message", level, str(title), str(text)) + + +# ------------------------------------------------------------------ хранилище +_st_lock = threading.Lock() + + +def _storage_path(plugin_file): + safe = re.sub(r"[^\w.\-]", "_", plugin_file, flags=re.ASCII) + return os.path.join(STORAGE_DIR, safe + ".json") + + +def _storage_read(plugin_file): + path = _storage_path(plugin_file) + with _st_lock: + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return data + except (OSError, ValueError): + pass + return {} + + +def _storage_write(plugin_file, data): + if not isinstance(data, dict): + return + path = _storage_path(plugin_file) + with _st_lock: + try: + os.makedirs(STORAGE_DIR, exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, path) + except OSError: + pass + + +# ------------------------------------------------------------------ Api +class _Api: + """API, которое видит плагин внутри on_load(api).""" + + def __init__(self, plugin): + self._p = plugin + + # --- события и кнопки + def on(self, event, fn): + self._p.on(event, fn) + + def add_action(self, label, fn): + self._p.add_action(label, fn) + + # --- журнал и окна + def log(self, message): + _call_log("[%s] %s" % (self._p.name, message)) + + def show_message(self, title, text): + _ui_message("info", title, text) + + def show_warning(self, title, text): + _ui_message("warn", title, text) + + def ask_text(self, title, label, default=""): + res = _hook("ask_text", str(title), str(label), str(default)) + return res if isinstance(res, str) or res is None else None + + # --- состояние лаунчера + def get_minecraft_dir(self): + v = _hook("game_dir") + return v if isinstance(v, str) else config.DEFAULT_GAME_DIR + + def get_current_profile(self): + s = _hook("settings") or {} + v = s.get("active_profile") + return str(v) if v else None + + def get_active_account(self): + s = _hook("settings") or {} + return str(s.get("active_account") or "") + + def get_profiles(self): + s = _hook("settings") or {} + out = [] + for p in (s.get("profiles") or []): + name = str((p or {}).get("name") or "") + if name: + out.append(name) + return out + + def is_game_running(self): + return bool(_hook("game_running")) + + # --- своё хранилище + def get_storage(self): + return _storage_read(self._p.file) + + def save_storage(self, data): + _storage_write(self._p.file, data) + + +# ------------------------------------------------------------------ Plugin +class Plugin: + """Один загруженный плагин.""" + + def __init__(self, path): + self.path = path + self.file = os.path.basename(path) + self.name = self.file[:-3] if self.file.endswith(".py") else self.file + self.version = "" + self.author = "" + self.description = "" + self.module = None + self.handlers = {} # event -> [callable] + self.actions = [] # [(label, callable)] + + def load(self): + """Импортирует файл и вызывает on_load(api).""" + modname = "pixelplugin_" + re.sub(r"\W", "_", self.file[:-3]) + spec = importlib.util.spec_from_file_location(modname, self.path) + if spec is None or spec.loader is None: + raise ImportError("нельзя построить спецификацию модуля") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + self.module = mod + self.name = str(getattr(mod, "PLUGIN_NAME", "") or self.name) + self.version = str(getattr(mod, "PLUGIN_VERSION", "") or "") + self.author = str(getattr(mod, "PLUGIN_AUTHOR", "") or "") + self.description = str(getattr(mod, "PLUGIN_DESCRIPTION", "") or "") + on_load = getattr(mod, "on_load", None) + if callable(on_load): + on_load(_Api(self)) + return self + + def on(self, event, fn): + if event not in EVENTS: + _call_log("Плагин %s: неизвестное событие «%s»" + % (self.file, event)) + return + if callable(fn): + self.handlers.setdefault(event, []).append(fn) + + def add_action(self, label, fn): + if callable(fn): + self.actions.append((str(label), fn)) + + def emit(self, event, **kw): + for fn in list(self.handlers.get(event, ())): + try: + fn(**kw) + except Exception as e: # noqa: BLE001 + _call_log("Плагин %s: ошибка события %s: %s" + % (self.file, event, e)) + + def unload(self): + fn = getattr(self.module, "on_unload", None) if self.module else None + if callable(fn): + try: + fn() + except Exception as e: # noqa: BLE001 + _call_log("Плагин %s: on_unload: %s" % (self.file, e)) + self.handlers.clear() + self.actions.clear() + + +# ------------------------------------------------------------------ менеджер +_loaded = [] +_failures = [] +_mgr_lock = threading.Lock() +_scan_lock = threading.Lock() + + +def plugin_files(): + try: + return sorted(f for f in os.listdir(PLUGIN_DIR) + if f.endswith(".py") and not f.startswith(("_", "."))) + except OSError: + return [] + + +def scan(): + """(Пере)загружает все плагины. Возвращает (загружено, ошибок).""" + with _scan_lock: + unload_all() + loaded, failures = [], [] + try: + os.makedirs(PLUGIN_DIR, exist_ok=True) + except OSError: + pass + for fname in plugin_files(): + p = Plugin(os.path.join(PLUGIN_DIR, fname)) + try: + p.load() + loaded.append(p) + except Exception as e: # noqa: BLE001 + short = "%s: %s" % (type(e).__name__, e) + failures.append((fname, short)) + _call_log("Плагин %s не загрузился: %s" % (fname, short)) + try: + traceback.print_exc() + except Exception: # noqa: BLE001 + pass + with _mgr_lock: + _loaded[:] = loaded + _failures[:] = failures + if loaded: + _call_log("Плагины: загружено %d%s" + % (len(loaded), + ", ошибок %d" % len(failures) if failures else "")) + return len(loaded), len(failures) + + +def unload_all(): + with _mgr_lock: + old = list(_loaded) + _loaded.clear() + for p in old: + p.unload() + + +def loaded(): + with _mgr_lock: + return list(_loaded) + + +def failures(): + with _mgr_lock: + return list(_failures) + + +def emit(event, **kw): + """Рассылает событие всем загруженным плагинам.""" + for p in loaded(): + p.emit(event, **kw) + + +def actions(): + """Кнопки плагинов: [(label, fn, plugin_name)].""" + out = [] + for p in loaded(): + for label, fn in list(p.actions): + out.append((label, fn, p.name)) + return out + + +# ------------------------------------------------------------------ установка +def install_file(filename, data): + """Сохраняет файл плагина в папку plugins. Возвращает путь.""" + safe = os.path.basename(str(filename).replace("\\", "/")) + if (not safe.endswith(".py") or len(safe) > 100 + or safe.startswith(("_", ".")) or "/" in safe): + raise ValueError("недопустимое имя файла плагина: %r" % filename) + if isinstance(data, str): + data = data.encode("utf-8") + os.makedirs(PLUGIN_DIR, exist_ok=True) + path = os.path.join(PLUGIN_DIR, safe) + tmp = path + ".tmp" + with open(tmp, "wb") as f: + f.write(data) + os.replace(tmp, path) + return path + + +def remove_file(filename): + """Удаляет установленный плагин из локальной папки.""" + safe = os.path.basename(str(filename).replace("\\", "/")) + path = os.path.join(PLUGIN_DIR, safe) + if os.path.isfile(path): + os.remove(path) + return True + return False + + +def fetch_catalog(timeout=15): + """Каталог с сайта: [{file, title, desc, ...}, ...].""" + from . import http + raw = http.get_bytes(CATALOG_URL, timeout=timeout) + data = json.loads(raw.decode("utf-8")) + if not isinstance(data, list): + raise ValueError("пустой каталог плагинов") + return [x for x in data + if isinstance(x, dict) and isinstance(x.get("file"), str) + and x["file"]] + + +def download_plugin(filename, timeout=30): + """Скачивает плагин из магазина и ставит локально. Возвращает путь.""" + from urllib.parse import quote + from . import http + safe = os.path.basename(str(filename).replace("\\", "/")) + data = http.get_bytes(STORE_BASE + "/" + quote(safe), timeout=timeout) + return install_file(safe, data) diff --git a/launcher/protocol.py b/launcher/protocol.py new file mode 100644 index 0000000..1654643 --- /dev/null +++ b/launcher/protocol.py @@ -0,0 +1,173 @@ +"""Протокол pixellauncher:// — ссылки с сайта открывает лаунчер. + +Схемы: + pixellauncher://plugin/<файл.py> — установить плагин из магазина + pixellauncher://mrpack/ — скачать и импортировать модпак + +Реестр Windows: HKCU\\Software\\Classes\\pixellauncher (пишет установщик). + +Одна живая копия лаунчера: .proto/instance.json с PID. Если лаунчер уже +запущен, новый экземпляр кладёт ссылку в очередь (.proto/q-*.json) и +выходит — работающая копия сама подхватывает очередь. +""" + +import json +import os +import re +import time + +from . import config + +PROTO_DIR = os.path.join(config.APP_DIR, ".proto") +_INSTANCE = os.path.join(PROTO_DIR, "instance.json") + + +def parse(url): + """Разбирает ссылку. Возвращает (kind, target) или None.""" + if not isinstance(url, str): + return None + prefix = "pixellauncher://" + if not url.lower().startswith(prefix): + return None + rest = url[len(prefix):] + low = rest.lower() + if low.startswith("plugin/"): + name = rest[len("plugin/"):] + name = name.split("?")[0].split("#")[0].strip() + try: + from urllib.parse import unquote + name = unquote(name) + except Exception: # noqa: BLE001 + pass + if (not name or len(name) > 100 or "/" in name or "\\" in name + or name.startswith(".") or not name.endswith(".py")): + return None + return ("plugin", name) + if low.startswith("mrpack/"): + target = rest[len("mrpack/"):] + try: + from urllib.parse import unquote + target = unquote(target) + except Exception: # noqa: BLE001 + pass + if target.startswith("https://") or target.startswith("http://"): + return ("mrpack", target) + return None + return None + + +# ------------------------------------------------------------------ инстанс +def _pid_alive(pid): + if pid <= 0: + return False + if os.name == "nt": + import ctypes + k32 = ctypes.windll.kernel32 + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + STILL_ACTIVE = 259 + h = k32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) + if not h: + return False + try: + code = ctypes.c_ulong() + if (k32.GetExitCodeProcess(h, ctypes.byref(code)) + and code.value == STILL_ACTIVE): + return True + return False + finally: + k32.CloseHandle(h) + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def other_instance_running(): + """True, если лаунчер с этим PID уже живёт (не мы сами).""" + try: + with open(_INSTANCE, encoding="utf-8") as f: + pid = int(json.load(f).get("pid", 0)) + except (OSError, ValueError, TypeError, AttributeError): + return False + if pid == os.getpid(): + return False + return _pid_alive(pid) + + +def mark_instance(): + try: + os.makedirs(PROTO_DIR, exist_ok=True) + with open(_INSTANCE, "w", encoding="utf-8") as f: + json.dump({"pid": os.getpid(), "ts": int(time.time())}, f) + except OSError: + pass + + +def clear_instance(): + """Снимает свою отметку (чужую — не трогает).""" + try: + with open(_INSTANCE, encoding="utf-8") as f: + if int(json.load(f).get("pid", 0)) != os.getpid(): + return + except (OSError, ValueError, TypeError, AttributeError): + pass + try: + os.remove(_INSTANCE) + except OSError: + pass + + +# ------------------------------------------------------------------ очередь +def queue_url(url): + """Кладёт ссылку в очередь для уже запущенного лаунчера.""" + try: + os.makedirs(PROTO_DIR, exist_ok=True) + name = "q-%d-%d.json" % (int(time.time() * 1000), os.getpid()) + with open(os.path.join(PROTO_DIR, name), "w", + encoding="utf-8") as f: + json.dump({"url": url, "ts": int(time.time())}, f) + return True + except OSError: + return False + + +def queue_files(): + """Читает и удаляет ссылки из очереди: [(имя, url), ...].""" + out = [] + try: + names = sorted(f for f in os.listdir(PROTO_DIR) + if f.startswith("q-") and f.endswith(".json")) + except OSError: + return out + for name in names: + path = os.path.join(PROTO_DIR, name) + url = "" + try: + with open(path, encoding="utf-8") as f: + url = str(json.load(f).get("url") or "") + except (OSError, ValueError, TypeError, AttributeError): + pass + try: + os.remove(path) + except OSError: + pass + if url: + out.append((name, url)) + return out + + +# ------------------------------------------------------------------ загрузка +def fetch_mrpack(url, timeout=120): + """Скачивает .mrpack в папку очереди. Возвращает локальный путь.""" + from . import http + os.makedirs(PROTO_DIR, exist_ok=True) + base = url.rstrip("/").split("/")[-1] or "pack.mrpack" + base = re.sub(r"[^\w.\-]", "_", base) + if not base.endswith(".mrpack"): + base += ".mrpack" + path = os.path.join(PROTO_DIR, "incoming-" + base) + data = http.get_bytes(url, timeout=timeout) + with open(path, "wb") as f: + f.write(data) + return path diff --git a/launcher/selfupdate.py b/launcher/selfupdate.py new file mode 100644 index 0000000..36333d5 --- /dev/null +++ b/launcher/selfupdate.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +"""Автообновление лаунчера: проверка манифеста, скачивание установщика. + +Манифест на сервере — /update.json: + {"version": "2.1.0", "url": "/download/PixelLauncherSetup2.1.0.exe", + "sha256": "…", "size": 15418378, "notes": "…"} +url относительный (база — UPDATE_BASE), sha256/size необязательны, +но sha256 проверяется, если указан. + +check() — манифест, если версия новее текущей; None — обновления нет; + ошибки сети/формата — RuntimeError. +download() — скачать установщик и проверить SHA-256, вернуть путь. +run_installer() — запустить установщик (caller закрывает лаунчер). +""" + +import hashlib +import json +import os +import sys +import tempfile + +from . import LAUNCHER_VERSION, http + +# База обновлений — официальный домен (HTTPS, сертификат Let's Encrypt). +UPDATE_BASE = "https://pixellauncher.ru" +MANIFEST_URL = UPDATE_BASE + "/update.json" + + +def _vtuple(version): + """"1.10" -> (1, 10); нечисловые куски — ноль; хвостовые нули срезаем + (1.0.0.0 == 1.0).""" + out = [] + for part in str(version).strip().split("."): + part = part.strip() + out.append(int(part) if part.isdigit() else 0) + while len(out) > 1 and out[-1] == 0: + out.pop() + return tuple(out) + + +def is_newer(version): + """Есть ли в version что-то новее, чем установлено.""" + return _vtuple(version) > _vtuple(LAUNCHER_VERSION) + + +def check(timeout=15): + """Манифест с версией новее текущей, иначе None. Бросает RuntimeError.""" + try: + raw = http.get_bytes(MANIFEST_URL, timeout=timeout) + manifest = json.loads(raw.decode("utf-8")) + except Exception as e: # noqa: BLE001 + raise RuntimeError(f"нет связи с сервером обновлений: {e}") from e + if not isinstance(manifest, dict) \ + or not str(manifest.get("version") or "").strip(): + raise RuntimeError("сервер прислал некорректный манифест") + if not is_newer(manifest["version"]): + return None + return manifest + + +def sha256_file(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 16), b""): + h.update(chunk) + return h.hexdigest() + + +def _installer_path(url): + name = url.split("?")[0].rstrip("/").split("/")[-1] or "update.exe" + if not name.lower().endswith(".exe"): + name = "update.exe" + return os.path.join(tempfile.gettempdir(), name) + + +def download(manifest, progress=None, cancel=None): + """Скачивает установщик из манифеста, сверяет SHA-256. Возвращает путь. + + progress(done_bytes, total_bytes|None); cancel — threading.Event. + """ + url = str(manifest.get("url") or "") + if not url: + raise RuntimeError("в манифесте обновления нет url") + fetch = url + if os.path.isabs(url): + from pathlib import Path + fetch = Path(url).as_uri() # C:/… -> file:///C:/… + elif url.startswith("file:"): + fetch = url + elif "://" not in url: + url = UPDATE_BASE + url # относительный путь /download/… + fetch = url + path = _installer_path(url) + want = str(manifest.get("sha256") or "").strip().lower() + size = int(manifest.get("size") or 0) or None + + if os.path.isfile(path): + if want: + if sha256_file(path) == want: + if progress: + total = os.path.getsize(path) + progress(total, total) + return path + try: # битый/устаревший остаток — качаем заново + os.remove(path) + except OSError: + pass + elif size and os.path.getsize(path) == size: + return path + + http.download_file(fetch, path, size=size, progress=progress, + cancel=cancel) + if want: + got = sha256_file(path) + if got != want: + try: + os.remove(path) + except OSError: + pass + raise RuntimeError("SHA-256 не совпал — файл обновления битый") + return path + + +def run_installer(path): + """Запускает установщик. После вызова лаунчер нужно завершить.""" + if not os.path.isfile(path): + raise RuntimeError("нет файла установщика: " + str(path)) + if sys.platform != "win32": + raise RuntimeError("автоустановка поддерживается только в Windows") + os.startfile(path) # noqa: S606 diff --git a/launcher/stats.py b/launcher/stats.py new file mode 100644 index 0000000..c61cb1f --- /dev/null +++ b/launcher/stats.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +"""История запусков и статистика игры. + +Всё хранится в .launcher/stats.json рядом с кэшем Java: + plays — {версия: {count, seconds, last, crashes}} + launches — последние запуски (кап 300): {ts, ver, ok, code, seconds} +""" + +import json +import os +import threading +import time + +_FILE = None # путь к stats.json (задаётся set_path) +_LOCK = threading.Lock() +_MAX_LAUNCHES = 300 + + +def set_path(launcher_dir): + """Каталог .launcher — тут лежат stats.json и java_cache.json.""" + global _FILE + _FILE = os.path.join(str(launcher_dir), "stats.json") + + +def _load(): + try: + with open(_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError + return data + except (OSError, ValueError): + return {"plays": {}, "launches": []} + + +def _save(data): + tmp = _FILE + ".tmp" + try: + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp, _FILE) + except OSError: + pass + + +def record_start(version): + """Вызывается при запуске игры: +1 к счётчику запусков версии.""" + if not _FILE: + return + with _LOCK: + data = _load() + plays = data.setdefault("plays", {}) + rec = plays.setdefault(version, {"count": 0, "seconds": 0, + "last": 0.0, "crashes": 0}) + rec["count"] += 1 + rec["last"] = time.time() + _save(data) + + +def record_finish(version, seconds, ok, code): + """Вызывается при завершении игры: длительность, код, вылет.""" + if not _FILE: + return + with _LOCK: + data = _load() + plays = data.setdefault("plays", {}) + rec = plays.setdefault(version, {"count": 0, "seconds": 0, + "last": 0.0, "crashes": 0}) + rec["seconds"] = max(0, int(seconds)) + if not ok: + rec["crashes"] = rec.get("crashes", 0) + 1 + launches = data.setdefault("launches", []) + launches.append({"ts": time.time(), "ver": version, "ok": bool(ok), + "code": code, "seconds": max(0, int(seconds))}) + del launches[:-_MAX_LAUNCHES] + _save(data) + + +def totals(): + """Агрегаты для окна статистики.""" + if not _FILE: + return {"total_seconds": 0, "total_launches": 0, + "per_version": [], "recent": []} + with _LOCK: + data = _load() + plays = data.get("plays") or {} + per = [] + total_s = 0 + for ver, rec in plays.items(): + sec = int(rec.get("seconds") or 0) + total_s += sec + per.append({"ver": ver, "count": int(rec.get("count") or 0), + "seconds": sec, "last": float(rec.get("last") or 0), + "crashes": int(rec.get("crashes") or 0)}) + per.sort(key=lambda r: r["last"], reverse=True) + recent = list(data.get("launches") or [])[-20:] + recent.reverse() + return {"total_seconds": total_s, + "total_launches": sum(r["count"] for r in per), + "per_version": per, "recent": recent} + + +def fmt_duration(seconds): + """1800 → «30 мин»; 30 → «30 с»; 3700 → «1 ч 1 мин».""" + seconds = int(seconds or 0) + if seconds < 60: + return f"{seconds} с" + if seconds < 3600: + return f"{seconds // 60} мин" + h, m = seconds // 3600, (seconds % 3600) // 60 + return f"{h} ч {m} мин" if m else f"{h} ч" + + +def fmt_time(ts): + """Unix-время → «дд.мм.гггг чч:мм».""" + if not ts: + return "—" + try: + return time.strftime("%d.%m.%Y %H:%M", time.localtime(ts)) + except (ValueError, OSError): + return "—" + + +def clear(): + """Полная очистка истории и статистики.""" + if not _FILE: + return + with _LOCK: + try: + os.remove(_FILE) + except OSError: + pass diff --git a/launcher/tray.py b/launcher/tray.py new file mode 100644 index 0000000..45a6946 --- /dev/null +++ b/launcher/tray.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +"""Иконка в трее Windows: Shell_NotifyIconW без сторонних библиотек. + +Скрытое окно с собственным message loop живёт в фоновом потоке; +колбэки вызываются в этом потоке — UI передаёт сюда только +потокобезопасные функции (положить сообщение в очередь Tk). +""" + +import ctypes +import threading +from ctypes import wintypes + +user32 = ctypes.windll.user32 +shell32 = ctypes.windll.shell32 +kernel32 = ctypes.windll.kernel32 + +WM_APP = 0x8000 +WM_TRAY = WM_APP + 1 # колбэк иконки +WM_TRAY_ACT = WM_APP + 2 # ЛКМ/даблклик → показать окно +WM_TRAY_CMD = WM_APP + 3 # пункт меню (wParam = индекс 1..n) + +NIM_ADD, NIM_MODIFY, NIM_DELETE = 0, 1, 2 +NIF_MESSAGE, NIF_ICON, NIF_TIP, NIF_INFO = 0x1, 0x2, 0x4, 0x10 +NIIF_INFO, NIIF_WARNING, NIIF_ERROR = 1, 2, 3 +WM_LBUTTONUP, WM_LBUTTONDBLCLK, WM_RBUTTONUP = 0x0202, 0x0203, 0x0205 +MF_STRING, MF_SEPARATOR = 0x0, 0x800 +TPM_RIGHTBUTTON, TPM_RETURNCMD = 0x2, 0x100 +IDI_APPLICATION = 32512 +GCLP_HICON = -14 +HWND_MESSAGE = wintypes.HWND(-3) +WM_NULL = 0 +WM_CLOSE = 0x10 +WM_DESTROY = 0x02 + +WNDPROC = ctypes.WINFUNCTYPE(ctypes.c_ssize_t, wintypes.HWND, ctypes.c_uint, + wintypes.WPARAM, wintypes.LPARAM) + +# сигнатуры для x64 (иначе хэндлы/указатели обрезаются до 32 бит) +user32.DefWindowProcW.restype = ctypes.c_ssize_t +user32.DefWindowProcW.argtypes = [wintypes.HWND, ctypes.c_uint, + wintypes.WPARAM, wintypes.LPARAM] +user32.CreatePopupMenu.restype = wintypes.HMENU +user32.AppendMenuW.argtypes = [wintypes.HMENU, ctypes.c_uint, wintypes.UINT, + wintypes.LPCWSTR] +user32.TrackPopupMenu.restype = ctypes.c_int +user32.TrackPopupMenu.argtypes = [wintypes.HMENU, ctypes.c_uint, + ctypes.c_int, ctypes.c_int, ctypes.c_int, + wintypes.HWND, ctypes.c_void_p] +user32.DestroyMenu.argtypes = [wintypes.HMENU] +user32.GetClassLongPtrW.restype = ctypes.c_size_t +user32.GetClassLongPtrW.argtypes = [wintypes.HWND, ctypes.c_int] +user32.LoadIconW.restype = wintypes.HICON +user32.LoadIconW.argtypes = [wintypes.HINSTANCE, ctypes.c_void_p] +kernel32.GetModuleHandleW.restype = wintypes.HINSTANCE +kernel32.GetModuleHandleW.argtypes = [wintypes.LPCWSTR] +shell32.Shell_NotifyIconW.restype = wintypes.BOOL +shell32.Shell_NotifyIconW.argtypes = [ctypes.c_uint, ctypes.c_void_p] +user32.PeekMessageW.argtypes = [ctypes.POINTER(wintypes.MSG), wintypes.HWND, + ctypes.c_uint, ctypes.c_uint, ctypes.c_uint] +user32.CreateWindowExW.argtypes = [ + ctypes.c_uint, wintypes.LPCWSTR, wintypes.LPCWSTR, ctypes.c_uint, + ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, + wintypes.HWND, wintypes.HMENU, wintypes.HINSTANCE, ctypes.c_void_p] +user32.CreateWindowExW.restype = wintypes.HWND +user32.GetCursorPos.argtypes = [ctypes.POINTER(wintypes.POINT)] +user32.PostMessageW.argtypes = [wintypes.HWND, ctypes.c_uint, + wintypes.WPARAM, wintypes.LPARAM] + + +class GUID(ctypes.Structure): + _fields_ = [("Data1", ctypes.c_uint32), ("Data2", ctypes.c_uint16), + ("Data3", ctypes.c_uint16), ("Data4", ctypes.c_ubyte * 8)] + + +class NIDW(ctypes.Structure): + _fields_ = [ + ("cbSize", wintypes.DWORD), + ("hWnd", wintypes.HWND), + ("uID", wintypes.UINT), + ("uFlags", wintypes.UINT), + ("uCallbackMessage", wintypes.UINT), + ("hIcon", wintypes.HICON), + ("szTip", wintypes.WCHAR * 128), + ("dwState", wintypes.DWORD), + ("dwStateMask", wintypes.DWORD), + ("szInfo", wintypes.WCHAR * 256), + ("uTimeout", wintypes.UINT), + ("szInfoTitle", wintypes.WCHAR * 64), + ("dwInfoFlags", wintypes.DWORD), + ("guidItem", GUID), + ("hBalloonIcon", wintypes.HICON), + ] + + +class WNDCLASSW(ctypes.Structure): + _fields_ = [ + ("style", ctypes.c_uint), + ("lpfnWndProc", WNDPROC), + ("cbClsExtra", ctypes.c_int), + ("cbWndExtra", ctypes.c_int), + ("hInstance", wintypes.HINSTANCE), + ("hIcon", wintypes.HICON), + ("hCursor", wintypes.HCURSOR), + ("hbrBackground", wintypes.HBRUSH), + ("lpszMenuName", wintypes.LPCWSTR), + ("lpszClassName", wintypes.LPCWSTR), + ] + + +class Tray: + """Трей: start() → иконка, notify() → плашка, stop() → убрать.""" + + def __init__(self, on_activate=None, on_command=None, commands=()): + self._on_activate = on_activate or (lambda: None) + self._on_command = on_command or (lambda name: None) + self._commands = list(commands) + self._hwnd = None + self._thread = None + self._ready = threading.Event() + self._stop = threading.Event() + self._hicon = None + self._wndproc = WNDPROC(self._wnd_proc) # держим живым + + # ------------------------------------------------------------- поток + def start(self, tooltip="", hicon=None): + self._hicon = hicon or user32.LoadIconW(None, IDI_APPLICATION) + self._tooltip = tooltip or "PixelLauncher" + self._thread = threading.Thread(target=self._loop, daemon=True, + name="tray") + self._thread.start() + self._ready.wait(5) + + def _loop(self): + cls = "PixelLauncherTrayClass" + wc = WNDCLASSW() + wc.lpfnWndProc = self._wndproc + wc.hInstance = kernel32.GetModuleHandleW(None) + wc.lpszClassName = cls + user32.RegisterClassW(ctypes.byref(wc)) + hwnd = user32.CreateWindowExW( + 0, cls, "PixelLauncher", 0, 0, 0, 0, 0, + HWND_MESSAGE, None, wc.hInstance, None) + if not hwnd: + self._ready.set() + return + self._hwnd = hwnd + self._shell(NIM_ADD, NIF_MESSAGE | NIF_ICON | NIF_TIP, + callback=WM_TRAY, tip=self._tooltip, icon=self._hicon) + self._ready.set() + msg = wintypes.MSG() + while not self._stop.is_set(): + # PeekMessage, чтобы периодически проверять флаг остановки + while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 1): + user32.TranslateMessage(ctypes.byref(msg)) + user32.DispatchMessageW(ctypes.byref(msg)) + if msg.message == WM_DESTROY: + return + if not self._stop.is_set(): + # усыпляемся до следующего сообщения + user32.WaitMessage() + user32.DestroyWindow(hwnd) + + # ----------------------------------------------------------- колбэки + def _wnd_proc(self, hwnd, msg, wparam, lparam): + if msg == WM_TRAY: + if lparam in (WM_LBUTTONUP, WM_LBUTTONDBLCLK): + self._on_activate() + elif lparam == WM_RBUTTONUP: + self._popup(hwnd) + return 0 + if msg == WM_TRAY_ACT: + self._on_activate() + return 0 + if msg == WM_TRAY_CMD: + if int(wparam) == 100: + self._on_command("exit") + else: + idx = int(wparam) - 2 + if 0 <= idx < len(self._commands): + self._on_command(self._commands[idx][1]) + return 0 + if msg == WM_DESTROY: + user32.PostQuitMessage(0) + return 0 + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + + def _popup(self, hwnd): + hm = user32.CreatePopupMenu() + user32.AppendMenuW(hm, MF_STRING, 1, "Показать лаунчер") + user32.AppendMenuW(hm, MF_SEPARATOR, 0, None) + for i, (label, _name) in enumerate(self._commands): + user32.AppendMenuW(hm, MF_STRING, i + 2, label) + user32.AppendMenuW(hm, MF_SEPARATOR, 0, None) + user32.AppendMenuW(hm, MF_STRING, 100, "Выход") + user32.SetForegroundWindow(hwnd) + pt = wintypes.POINT() + user32.GetCursorPos(ctypes.byref(pt)) + cmd = user32.TrackPopupMenu(hm, TPM_RIGHTBUTTON | TPM_RETURNCMD, + pt.x, pt.y, 0, hwnd, None) + user32.PostMessageW(hwnd, WM_NULL, 0, 0) + user32.DestroyMenu(hm) + if cmd == 1: + user32.PostMessageW(hwnd, WM_TRAY_ACT, 0, 0) + elif cmd == 100: + user32.PostMessageW(hwnd, WM_TRAY_CMD, 100, 0) + elif cmd: + user32.PostMessageW(hwnd, WM_TRAY_CMD, cmd, 0) + + # -------------------------------------------------------------- API + def _shell(self, action, flags, callback=0, tip="", icon=None, + info="", title="", niif=NIIF_INFO): + if not self._hwnd: + return + nid = NIDW() + nid.cbSize = ctypes.sizeof(NIDW) + nid.hWnd = self._hwnd + nid.uID = 1 + nid.uFlags = flags + nid.uCallbackMessage = callback + nid.hIcon = icon or self._hicon + nid.szTip = (tip or "")[:127] + nid.szInfo = (info or "")[:255] + nid.szInfoTitle = (title or "")[:63] + nid.dwInfoFlags = niif + shell32.Shell_NotifyIconW(action, ctypes.byref(nid)) + + def notify(self, title, text, warn=False): + """Плашка в трее (из любого потока).""" + self._shell(NIM_MODIFY, NIF_INFO, title=str(title)[:63], + info=str(text)[:255], + niif=NIIF_WARNING if warn else NIIF_INFO) + + def update_tooltip(self, text): + self._shell(NIM_MODIFY, NIF_TIP, tip=str(text)[:127]) + + def stop(self): + """Убирает иконку (можно из любого потока).""" + if self._stop.is_set(): + return + self._stop.set() + if self._hwnd: + self._shell(NIM_DELETE, 0) + user32.PostMessageW(self._hwnd, WM_CLOSE, 0, 0) + + +def window_icon(hwnd): + """HICON окна Tk для иконки трея (None — если не вышло).""" + try: + h = user32.GetClassLongPtrW(hwnd, GCLP_HICON) + return h or None + except Exception: # noqa: BLE001 + return None diff --git a/launcher/ui.py b/launcher/ui.py new file mode 100644 index 0000000..8381c1d --- /dev/null +++ b/launcher/ui.py @@ -0,0 +1,5269 @@ +"""PixelLauncher — компактный тёмный лаунчер в духе LiteLauncher. + +Минималистичный дизайн: узкая панель с логотипом и ником сверху, пиксельный +пейзаж-баннер по центру, большая зелёная кнопка «ИГРАТЬ» и пилюля с версией. +Без сторонних библиотек (tkinter). +""" + +import ctypes +import json +import math +import os +import queue +import random +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import tkinter as tk +import webbrowser +from ctypes import wintypes +from tkinter import filedialog, messagebox, ttk + +from . import config, crash as crash_mod, downloader, forge as forge_mod, http +from . import java as java_mod, java_fetch +from . import launch as launch_mod +from . import manifest as manifest_mod +from . import mods as mods_mod +from . import modrinth as modrinth_mod +from . import mrpack as mrpack_mod +from . import plugins as plugins_mod +from . import protocol as protocol_mod +from . import selfupdate +from . import stats as stats_mod +from . import tray as tray_mod +from . import version as version_mod +from . import worlds as worlds_mod +from .i18n import T, set_language +from . import LAUNCHER_VERSION + +CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0 + +# ------------------------------------------------------------------ палитра +BG = "#141414" +BAR = "#1b1b1b" +BAR_BD = "#2c2c2c" +PANEL = "#1e1e1e" +INPUT = "#101010" +INPUT_BD = "#333333" +HOVER = "#2a2a2a" +TEXT = "#f0f0f0" +DIM = "#8a8a8a" +FAINT = "#5c5c5c" +GREEN = "#6fae4a" +GREEN_HI = "#7fc257" +GREEN_LO = "#40702c" +GREEN_BD = "#2c4a1e" +OK = "#7fc257" +ERR = "#e0655f" +WARN = "#d8a13a" +PILL = "#242424" +PILL_BD = "#3a3a3a" + +THEME = "dark" # текущая тема: "dark" или "light" + +# Хром окна по темам (фоны/границы/текст). Акцент (GREEN*) считается +# отдельно через set_accent и от темы не зависит. +THEMES = { + "dark": {"BG": "#141414", "BAR": "#1b1b1b", "BAR_BD": "#2c2c2c", + "PANEL": "#1e1e1e", "INPUT": "#101010", "INPUT_BD": "#333333", + "HOVER": "#2a2a2a", "TEXT": "#f0f0f0", "DIM": "#8a8a8a", + "FAINT": "#5c5c5c", "PILL": "#242424", "PILL_BD": "#3a3a3a", + "OK": "#7fc257", "ERR": "#e0655f", "WARN": "#d8a13a"}, + "light": {"BG": "#ededed", "BAR": "#f9f9f9", "BAR_BD": "#d4d4d4", + "PANEL": "#f6f6f6", "INPUT": "#ffffff", "INPUT_BD": "#c9c9c9", + "HOVER": "#e1e1e1", "TEXT": "#1c1c1c", "DIM": "#6b6b6b", + "FAINT": "#9b9b9b", "PILL": "#e7e7e7", "PILL_BD": "#d0d0d0", + "OK": "#4e9a34", "ERR": "#cc4038", "WARN": "#a8791a"}, +} + + +def set_theme(name): + """Меняет тему оформления (тёмная/светлая). + + Как и set_accent — читается в момент отрисовки, поэтому после вызова + нужно пересобрать виджеты: self.configure(bg=BG) + _rebuild_ui(). + """ + global THEME, BG, BAR, BAR_BD, PANEL, INPUT, INPUT_BD, HOVER, \ + TEXT, DIM, FAINT, PILL, PILL_BD, OK, ERR, WARN + THEME = name if name in THEMES else "dark" + t = THEMES[THEME] + BG, BAR, BAR_BD = t["BG"], t["BAR"], t["BAR_BD"] + PANEL, INPUT, INPUT_BD = t["PANEL"], t["INPUT"], t["INPUT_BD"] + HOVER, TEXT, DIM, FAINT = t["HOVER"], t["TEXT"], t["DIM"], t["FAINT"] + PILL, PILL_BD = t["PILL"], t["PILL_BD"] + OK, ERR, WARN = t["OK"], t["ERR"], t["WARN"] + +TYPE_RU = {"release": "релиз", "snapshot": "снапшот", + "old_alpha": "old alpha", "old_beta": "old beta"} + +WIN_W, WIN_H = 660, 640 +BANNER_W, BANNER_H = 560, 300 + + +def mix(c1, c2, t): + a = [int(c1[i:i + 2], 16) for i in (1, 3, 5)] + b = [int(c2[i:i + 2], 16) for i in (1, 3, 5)] + return "#%02x%02x%02x" % tuple(round(a[i] + (b[i] - a[i]) * t) + for i in range(3)) + + +def set_accent(hex_color): + """Меняет акцентный цвет (кнопка ИГРАТЬ, прогресс, выделение списков). + + Читается в момент отрисовки, поэтому достаточно перерисовать виджеты. + """ + global GREEN, GREEN_HI, GREEN_LO, GREEN_BD, ACCENT + ACCENT = hex_color + GREEN = hex_color + GREEN_HI = mix(hex_color, "#ffffff", 0.18) + GREEN_LO = mix(hex_color, "#000000", 0.42) + GREEN_BD = mix(hex_color, "#000000", 0.62) + + +def round_rect(c, x0, y0, x1, y1, r, fill, outline="", width=1): + """Скруглённый прямоугольник на Canvas.""" + c.create_polygon(x0 + r, y0, x1 - r, y0, x1, y0, x1, y0 + r, + x1, y1 - r, x1, y1, x1 - r, y1, x0 + r, y1, + x0, y1, x0, y1 - r, x0, y0 + r, x0, y0, + smooth=True, fill=fill, outline=outline, width=width) + + +def round_mask(cv, w, h, r, bg, seg=8): + """Закрашивает углы холста цветом фона — эффект скруглённого баннера.""" + def arc(cx, cy, a0, a1): + pts = [] + for i in range(seg + 1): + a = math.radians(a0 + (a1 - a0) * i / seg) + pts += [cx + r * math.cos(a), cy + r * math.sin(a)] + return pts + + for pts in ([0, 0] + arc(r, r, -90, -180), + [w, 0] + arc(w - r, r, -90, 0), + [0, h] + arc(r, h - r, 180, 90), + [w, h] + arc(w - r, h - r, 0, 90)): + cv.create_polygon(pts, fill=bg, outline="") + + +# ------------------------------------------- пиксельный шрифт 5x7 +PIXEL_FONT = { + "A": (".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"), + "C": (".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."), + "E": ("#####", "#....", "#....", "####.", "#....", "#....", "#####"), + "H": ("#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"), + "I": ("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "#####"), + "L": ("#....", "#....", "#....", "#....", "#....", "#....", "#####"), + "N": ("#...#", "##..#", "#.#.#", "#.#.#", "#..##", "#...#", "#...#"), + "P": ("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."), + "R": ("####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"), + "U": ("#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."), + "X": ("#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#"), + "И": ("#...#", "#...#", "#..##", "#.#.#", "##..#", "#...#", "#...#"), + "Г": ("#####", "#....", "#....", "#....", "#....", "#....", "#...."), + "Т": ("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."), + "Ь": ("#....", "#....", "#....", "#....", "####.", "#...#", "####."), + "А": (".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"), + "Р": ("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."), +} + + +def pixel_text_width(text, scale): + return max(0, len(text) * 6 * scale - scale) + + +def draw_pixel_text(cv, x, y, text, scale, fill, shadow=None, sdx=3, sdy=3): + """Рисует текст пиксельным шрифтом 5x7 (по клетке на пиксель).""" + if shadow: + _blit_text(cv, x + sdx, y + sdy, text, scale, shadow) + return _blit_text(cv, x, y, text, scale, fill) + + +def _blit_text(cv, x, y, text, scale, fill): + ox = x + for ch in text.upper(): + glyph = PIXEL_FONT.get(ch) + if glyph is None: # пробел и неизвестные — просто отступ + ox += 6 * scale + continue + for ry, row in enumerate(glyph): + for rx, c in enumerate(row): + if c == "#": + px0 = ox + rx * scale + py0 = y + ry * scale + cv.create_rectangle(px0, py0, px0 + scale, py0 + scale, + fill=fill, outline="") + ox += 6 * scale + return pixel_text_width(text, scale) + + +# ============================================================ пиксель-арт +def draw_scene(cv, w, h, night=False, scene=None): + """Пиксельный пейзаж. scene: day/night/nether/cave (None — по night).""" + if scene is None: + scene = "night" if night else "day" + if scene == "nether": + return _draw_nether(cv, w, h) + if scene == "cave": + return _draw_cave(cv, w, h) + night = scene == "night" + cell = 10 + cols, rows = w // cell, h // cell + tint = (lambda c: mix(c, "#0a1020", 0.55)) if night else (lambda c: c) + + def g(gx, gy, color, gx2=None, gy2=None): + cv.create_rectangle(gx * cell, gy * cell, + (gx2 if gx2 is not None else gx + 1) * cell, + (gy2 if gy2 is not None else gy + 1) * cell, + fill=color, outline="") + + def px(gx, gy, x0, y0, x1, y1, color): + """Пиксель внутри клетки (доли 0..1).""" + cv.create_rectangle(gx * cell + x0, gy * cell + y0, + gx * cell + x1, gy * cell + y1, + fill=color, outline="") + + ground = 19 # верхний ряд земли + + # Небо — градиент полосами во всю ширину + if night: + sky_top, sky_bot = "#0f1b33", "#1c2f52" + else: + sky_top, sky_bot = "#6fb6e6", "#b6e0f2" + for gy in range(ground): + color = mix(sky_top, sky_bot, gy / max(1, ground - 1)) + cv.create_rectangle(0, gy * cell, w, (gy + 1) * cell, + fill=color, outline="") + + # Звёзды (только ночью) + if night: + for sx, sy in ((5, 2), (12, 1), (18, 4), (24, 2), (21, 1), (9, 6), + (40, 1), (44, 3), (50, 2), (54, 5), (47, 7), (26, 5)): + g(sx, sy, "#cfd8ea") + + # Солнце с лучами / луна + if night: + for gx in range(32, 37): + for gy in range(1, 6): + if gx in (32, 36) or gy in (1, 5): + g(gx, gy, "#a9b4d2") + for gx in range(33, 36): + for gy in range(2, 5): + g(gx, gy, "#f4f7ff") + for sx, sy in ((31, 2), (31, 4), (37, 2), (37, 4)): + g(sx, sy, "#8d99bd") + else: + for gx in range(30, 37): + for gy in range(0, 7): + if gx in (30, 36) or gy in (0, 6): + g(gx, gy, "#e3f4fe") # ореол + else: + g(gx, gy, "#b8e2f7") + for gx in range(32, 35): + for gy in range(2, 5): + g(gx, gy, "#ffffff") # ядро + for sx, sy in ((29, 0), (29, 6), (37, 0), (37, 6), (29, 3), (37, 3)): + g(sx, sy, "#ffffff") # лучи + + # Облака + def cloud(cx, cy): + for dx, dy in ((0, 0), (1, 0), (2, 0), (3, 0), (1, -1), (2, -1), + (5, 0), (6, 0), (6, -1)): + g(cx + dx, cy + dy, tint("#ffffff")) + cloud(12, 2) + cloud(44, 4) + + # Дальние горы + for gx in range(cols): + top = 11 + ((gx * 7) % 5) + c1 = "#b3d0e3" if (gx + top) % 2 else "#a3c3d9" + for gy in range(top, 16): + g(gx, gy, tint(c1)) + + # Низкие кусты-холмы на горизонте + for gx in range(cols): + top = 16 + ((gx * 5) % 3) + g(gx, top, tint("#3a7a34")) + if top + 1 < ground: + g(gx, top + 1, tint("#347030")) + for gy in range(top + 2, ground): + g(gx, gy, tint("#2f682c")) + + # Поверхность: песчаная дорожка слева, трава справа + for gx in range(cols): + if gx <= 15: + g(gx, ground, tint("#d9c48b")) + px(gx, ground, 0, 0, 10, 3, tint("#e8d8a8")) + else: + g(gx, ground, tint("#6fae4a")) + px(gx, ground, 0, 0, 10, 3, tint("#8ec96a")) + + # Земля: грунт с крапинами и вкраплениями камня + dirt, dirt_d = "#8a5a33", "#6b4a2f" + stone, stone_d = "#6f6f6f", "#5a5a5a" + for gx in range(cols): + for gy in range(ground + 1, rows - 3): + base = dirt if (gx + gy) % 7 else dirt_d + g(gx, gy, tint(base)) + if (gx * 7 + gy * 13) % 11 == 0: + px(gx, gy, 3, 4, 6, 7, tint(dirt_d)) + if (gx * 5 + gy * 3) % 23 == 0: + px(gx, gy, 5, 2, 8, 5, tint(stone)) + for gy in range(rows - 3, rows): + base = stone if (gx * 3 + gy) % 5 else stone_d + g(gx, gy, tint(base)) + + # Каменный угол справа внизу (как на референсе) + for gx in range(44, cols): + for gy in range(rows - 5, rows - 3): + g(gx, gy, tint(stone if (gx + gy) % 3 else stone_d)) + + # Пруд слева + for gx in range(4, 14): + g(gx, ground + 1, tint("#3f7fd0")) + g(gx, ground + 2, tint("#3f7fd0")) + if (gx * 3) % 4 == 0: + px(gx, ground + 1, 1, 3, 7, 5, tint("#67a8e8")) + if gx in (4, 13): + g(gx, ground + 1, tint(dirt_d)) + g(gx, ground + 2, tint(dirt_d)) + + # Дерево (большая крона справа) + trunk, trunk_d = "#6b4a2f", "#54391f" + leaf, leaf_h, leaf_d = "#4f8f3f", "#63a952", "#3f7a32" + for gy in range(14, ground): + g(35, gy, tint(trunk)) + g(36, gy, tint(trunk_d)) + for gx in range(30, 43): + for gy in range(7, 14): + corner = (gx in (30, 42) and gy in (7, 13)) + g(gx, gy, tint(leaf_d if corner else leaf)) + for gx, gy in ((31, 8), (38, 9), (41, 11), (34, 12), (40, 8), (36, 10)): + g(gx, gy, tint(leaf_h)) + + # Сундук + for gx in range(20, 23): + g(gx, 17, tint("#c8913f")) + g(gx, 18, tint("#a87433")) + g(20, 17, tint("#8a5a33")) + g(22, 17, tint("#8a5a33")) + px(21, 17, 3, 3, 7, 8, tint("#e8c84a")) + + # Крипер (только голова над травой) + cr, cr_d = "#5da84e", "#4c8a40" + for gx in range(46, 50): + for gy in range(16, 19): + g(gx, gy, tint(cr_d if (gx + gy) % 5 == 0 else cr)) + for gx, gy in ((46, 16), (49, 16), (47, 17), (48, 17), + (47, 18), (48, 18)): + g(gx, gy, "#1c1c1c") + + # Цветы и травинки + for gx, color in ((17, "#d94040"), (25, "#e8d24a"), (41, "#d94040")): + px(gx, 18, 3, 0, 7, 4, tint(color)) + px(gx, 18, 4, 4, 6, 10, tint("#4f8f3f")) + for gx in (16, 27, 43, 52): + px(gx, 18, 2, 4, 5, 10, tint("#8ec96a")) + px(gx, 18, 6, 6, 8, 10, tint("#6fae4a")) + + +def _scene_helpers(cv, w, h): + """Общие помощники для пиксельных сцен: g() и px().""" + cell = 10 + cols, rows = w // cell, h // cell + + def g(gx, gy, color, gx2=None, gy2=None): + cv.create_rectangle(gx * cell, gy * cell, + (gx2 if gx2 is not None else gx + 1) * cell, + (gy2 if gy2 is not None else gy + 1) * cell, + fill=color, outline="") + + def px(gx, gy, x0, y0, x1, y1, color): + cv.create_rectangle(gx * cell + x0, gy * cell + y0, + gx * cell + x1, gy * cell + y1, + fill=color, outline="") + return g, px, cell, cols, rows + + +def _draw_nether(cv, w, h): + """Незер: багровое небо, лава, светокамень, порталь, огни.""" + g, px, cell, cols, rows = _scene_helpers(cv, w, h) + ground = 15 + + # Небо — багровый градиент + top, bot = "#3d1515", "#7a2c2c" + for gy in range(ground + 4): + col = mix(top, bot, gy / max(1, ground + 3)) + cv.create_rectangle(0, gy * cell, w, (gy + 1) * cell, + fill=col, outline="") + # Блики жара у горизонта + for gx in range(cols): + if (gx * 5) % 7 < 2: + px(gx, ground - 2, 2, 4, 8, 7, "#8f3a34") + + # Потолок: сталактиты из незеррека + nr, nr_d = "#5c2323", "#471a1a" + for gx in range(cols): + depth = 1 + ((gx * 7) % 3) + for gy in range(depth): + g(gx, gy, nr_d if (gx + gy) % 3 else nr) + if (gx * 11) % 13 == 0: + g(gx, depth, nr_d) + + # Лавовое озеро снизу + for gy in range(ground + 2, rows): + for gx in range(cols): + if gy == ground + 2 and (gx * 3) % 5: + continue # край озера неровный + c = "#e0641e" if (gx + gy) % 3 else "#c2521a" + g(gx, gy, c) + for gx in range(cols): + if (gx * 7) % 6 == 0: + px(gx, ground + 2, 1, 0, 7, 4, "#ffb52e") + if (gx * 3) % 11 == 0: + px(gx, ground + 3, 4, 3, 9, 7, "#ffd05a") + + # Берег: незеррек с крапинами + for gx in range(cols): + if (gx * 3) % 5 < 2: + continue + g(gx, ground, "#5c2323") + g(gx, ground + 1, "#471a1a" if gx % 2 else "#5c2323") + if (gx * 7 + 3) % 9 == 0: + px(gx, ground, 3, 3, 6, 6, "#3d1515") + + # Песок душ слева + for gx in range(3, 11): + g(gx, ground, "#8e7a5a") + if gx % 2: + px(gx, ground, 2, 2, 5, 5, "#7a6849") + # червонный нарост + if gx in (5, 7, 9): + g(gx, ground - 1, "#a03030") + px(gx, ground - 1, 3, 0, 6, 4, "#c04040") + + # Столбы базальта + for gx0, hgt in ((14, 5), (30, 3)): + for gx in (gx0, gx0 + 1): + for gy in range(ground - hgt, ground): + g(gx, gy, "#3a3a3f" if (gx + gy) % 3 else "#2e2e33") + + # Светокамень под потолком + ореол + for cx, cy in ((22, 3), (40, 2), (52, 4)): + for gx in range(cx, cx + 3): + for gy in range(cy, cy + 2): + g(gx, gy, "#f5d77a" if (gx + gy) % 2 else "#e0b95f") + for dx in (-1, 3): + g(cx + dx, cy, "#b98f45") + g(cx + 1, cy - 1, "#d4a954") + g(cx + 1, cy + 2, "#b98f45") + + # Огни на берегу + for gx in (18, 35, 47): + px(gx, ground - 1, 3, 2, 6, 10, "#ff9a2e") + px(gx, ground - 1, 4, 4, 5, 8, "#ffd05a") + + # Портал: обсидиановая рама с фиолетовым полем + for gx in range(42, 46): + g(gx, 10, "#1d1130") + g(gx, 14, "#1d1130") + for gy in range(10, 15): + g(42, gy, "#1d1130") + g(45, gy, "#1d1130") + for gx in range(43, 45): + for gy in range(11, 14): + g(gx, gy, "#8a3ff0" if (gx + gy) % 2 else "#6e2fd0") + px(43, 12, 1, 1, 4, 4, "#c79aff") + px(44, 11, 5, 5, 8, 8, "#b07af5") + + +def _draw_cave(cv, w, h): + """Пещера: тёмный камень, руды, лава, факел со светом.""" + g, px, cell, cols, rows = _scene_helpers(cv, w, h) + top, bot = "#1f2226", "#33383e" + + def stone_at(gy): + return mix(top, bot, gy / max(1, rows - 1)) + + # Каменная тьма полосами + for gy in range(rows): + cv.create_rectangle(0, gy * cell, w, (gy + 1) * cell, + fill=stone_at(gy), outline="") + + # Потолок со сталактитами + for gx in range(cols): + depth = 1 + ((gx * 5) % 4) + for gy in range(depth): + g(gx, gy, "#3a3f45" if (gx + gy) % 3 else "#2b2f34") + + # Крупный каменный крап на стенах + for gx in range(cols): + for gy in range(rows): + if (gx * 7 + gy * 13) % 17 == 0: + px(gx, gy, 2, 2, 6, 6, "#454b52" if gy > rows // 2 + else "#262a2f") + + floor = 16 + # Пол + for gx in range(cols): + for gy in range(floor, rows): + c = "#4a4f55" if (gx + gy) % 4 else "#3d4248" + g(gx, gy, c) + + # Лавовый ручей слева внизу + for gx in range(0, 13): + for gy in range(floor + 1, rows): + g(gx, gy, "#e0641e" if (gx + gy) % 3 else "#c2521a") + for gx in (2, 6, 10): + px(gx, floor + 1, 1, 0, 7, 4, "#ffb52e") + g(13, floor + 1, "#3d4248") + g(13, floor + 2, "#3d4248") + + # Руды: уголь, железо, золото, алмаз + ores = ( + (((16, 11), (17, 11), (16, 12)), "#2b2b2b", "#1e1e1e"), + (((33, 8), (34, 8), (33, 9)), "#c98d6b", "#a06b4c"), + (((47, 12), (48, 12), (48, 13)), "#f5cf5a", "#c9a53c"), + (((27, 13), (28, 13), (27, 14)), "#5ee8e0", "#3cb8b0"), + ) + for spots, c1, c2 in ores: + for i, (gx, gy) in enumerate(spots): + g(gx, gy, c1) + px(gx, gy, 1, 1, 4, 4, c2) + px(spots[0][0], spots[0][1], 6, 5, 9, 9, c2) + + # Факел на стене со световым ореолом + fx, fy = 40, 10 + for dx in range(-4, 5): + for dy in range(-4, 6): + dist = abs(dx) + abs(dy) + if dist == 0 or dist > 7: + continue + gx, gy = fx + dx, fy + dy + if not (0 <= gx < cols and 0 <= gy < rows): + continue + t = 0.34 * (8 - dist) / 7 + g(gx, gy, mix(stone_at(gy), "#ffe3a0", t)) + px(fx, fy, 3, 4, 7, 10, "#8a6a3f") + px(fx, fy, 4, 1, 6, 5, "#ffcf4a") + px(fx, fy, 4, 3, 6, 5, "#ff9a2e") + + # Грибы и травинки на полу + for gx, c in ((20, "#5aa0c0"), (22, "#c05a5a"), (36, "#5aa0c0")): + px(gx, floor, 3, 3, 7, 6, c) + px(gx, floor, 4, 6, 6, 10, "#d8d8d8") + + +def _skin_parts(full=True): + """Прямоугольники скина для фронтального вида. + + (sx, sy, ex, ey, dx, dy): источник в текстуре и положение на канве + (в клетках). full=False — старый формат 64x32 (без наложений). + """ + if full: + return [ + (8, 8, 16, 16, 4, 0), (40, 8, 48, 16, 4, 0), # голова + шляпа + (44, 20, 48, 32, 0, 8), (44, 36, 48, 48, 0, 8), # правая рука + (36, 52, 40, 64, 12, 8), (52, 52, 56, 64, 12, 8), # левая рука + (20, 20, 28, 32, 4, 8), (20, 36, 28, 48, 4, 8), # тело + куртка + (4, 20, 8, 32, 4, 20), (4, 36, 8, 48, 4, 20), # правая нога + (20, 52, 24, 64, 8, 20), (4, 52, 8, 64, 8, 20), # левая нога + ] + return [ + (8, 8, 16, 16, 4, 0), + (44, 20, 48, 32, 0, 8), (20, 20, 28, 32, 4, 8), + (44, 20, 48, 32, 12, 8), + (4, 20, 8, 32, 4, 20), (4, 20, 8, 32, 8, 20), + ] + + +def _png_rgba(path): + """Минимальный декодер PNG (8 бит, RGB/RGBA, без interlace). + + Tk умеет показывать PNG, но photo get отдаёт только RGB — альфа + теряется, а у скинов прозрачный оверлей лежит как (0,0,0). Декодируем + сами: (ширина, высота, байты RGBA) либо None, если формат не поддержан. + """ + try: + import struct + import zlib + data = open(path, "rb").read() + if data[:8] != b"\x89PNG\r\n\x1a\n": + return None + pos, idat, meta = 8, [], None + while pos + 8 <= len(data): + ln = struct.unpack(">I", data[pos:pos + 4])[0] + typ = data[pos + 4:pos + 8] + chunk = data[pos + 8:pos + 8 + ln] + if typ == b"IHDR": + meta = struct.unpack(">IIBBBBB", chunk) + elif typ == b"IDAT": + idat.append(chunk) + elif typ == b"IEND": + break + pos += 12 + ln + if not meta: + return None + w, h, depth, ctype, _c, _f, inter = meta + if depth != 8 or inter != 0 or ctype not in (2, 6): + return None + ch = 3 if ctype == 2 else 4 + raw = zlib.decompress(b"".join(idat)) + stride = w * ch + out = bytearray() + prev = bytearray(stride) + p = 0 + for _y in range(h): + ft = raw[p] # фильтр строки: 0..4 + p += 1 + line = bytearray(raw[p:p + stride]) + p += stride + if ft == 1: + for x in range(ch, stride): + line[x] = (line[x] + line[x - ch]) & 255 + elif ft == 2: + for x in range(stride): + line[x] = (line[x] + prev[x]) & 255 + elif ft == 3: + for x in range(stride): + a = line[x - ch] if x >= ch else 0 + line[x] = (line[x] + ((a + prev[x]) >> 1)) & 255 + elif ft == 4: + for x in range(stride): + a = line[x - ch] if x >= ch else 0 + c = prev[x - ch] if x >= ch else 0 + b = prev[x] + pp = a + b - c + pa, pb, pc = abs(pp - a), abs(pp - b), abs(pp - c) + pr = a if (pa <= pb and pa <= pc) else ( + b if pb <= pc else c) + line[x] = (line[x] + pr) & 255 + prev = line + if ch == 3: # RGB → RGBA + for x in range(w): + out += line[x * 3:x * 3 + 3] + b"\xff" + else: + out += line + return w, h, bytes(out) + except Exception: # noqa: BLE001 — любой битый файл + return None + + +# ============================================================ виджеты +class Icon_label(tk.Label): + """Невидимая кнопка-иконка в панели окна.""" + + def __init__(self, master, glyph, command, danger=False): + super().__init__(master, text=glyph, bg=BAR, fg=DIM, + font=("Segoe UI Symbol", 11), width=3, cursor="hand2") + self._cmd = command + self._danger = danger + self.bind("", self._enter) + self.bind("", self._leave) + self.bind("", self._click) + + def _enter(self, _=None): + self.configure(bg="#e81123" if self._danger else HOVER, + fg="#ffffff" if self._danger else TEXT) + + def _leave(self, _=None): + self.configure(bg=BAR, fg=DIM) + + def _click(self, _=None): + if self._cmd: + self._cmd() + + +class PlayButton(tk.Canvas): + """Большая зелёная кнопка в стиле Minecraft.""" + + def __init__(self, master, command, width=300, height=52): + super().__init__(master, width=width, height=height, bg=BG, + highlightthickness=0, cursor="hand2") + self.text = T("ИГРАТЬ") + self._cmd = command + self.h = height + self.state = "normal" + self.bind("", lambda e: self._draw()) + self.bind("", self._enter) + self.bind("", self._leave) + self.bind("", self._press) + self.bind("", self._release) + + def set_state(self, state): + self.state = state + self.configure(cursor="" if state == "disabled" else "hand2") + self._draw() + + def _enter(self, _=None): + if self.state == "normal": + self.state = "hover" + self._draw() + + def _leave(self, _=None): + if self.state in ("hover", "press"): + self.state = "normal" + self._draw() + + def _press(self, _=None): + if self.state == "disabled": + return + self.state = "press" + self._draw() + + def _release(self, _=None): + if self.state == "press": + self.state = "hover" + self._draw() + if self._cmd: + self._cmd() + + def _draw(self): + w = self.winfo_width() + if w < 40: + return + self.delete("all") + r, depth = 8, 5 + pressed = self.state == "press" + if self.state == "disabled": + if THEME == "light": + base, hi, lo, border = "#d5d5d5", "#e2e2e2", "#bcbcbc", "#a6a6a6" + tfg, tshadow = FAINT, "#9f9f9f" + else: + base, hi, lo, border = "#33383f", "#3d434b", "#262a30", "#20242a" + tfg, tshadow = FAINT, "#1a1a1a" + elif self.state in ("hover", "press"): + base, hi, lo, border = GREEN_HI, mix(GREEN_HI, "#ffffff", 0.35), GREEN, GREEN_BD + tfg, tshadow = "#ffffff", "#2c4a1e" + else: + base, hi, lo, border = GREEN, mix(GREEN, "#ffffff", 0.3), GREEN_LO, GREEN_BD + tfg, tshadow = "#ffffff", "#2c4a1e" + y_off = 1 if pressed else 0 + # нижняя грань (эффект глубины) + round_rect(self, 2, depth, w - 2, self.h, r, lo, border, 2) + # лицевая грань + round_rect(self, 2, y_off, w - 2, self.h - depth + y_off, r, + base, border, 2) + # блик сверху + self.create_line(14, y_off + 5, w - 14, y_off + 5, fill=hi, width=2) + # пиксельный текст с тенью + scale = 5 + tw = pixel_text_width(self.text, scale) + tx = (w - tw) // 2 + ty = y_off + (self.h - depth - 7 * scale) // 2 + draw_pixel_text(self, tx, ty, self.text, scale, tfg, tshadow, 2, 2) + + +class VersionPill(tk.Canvas): + """Пилюля с версией игры под кнопкой.""" + + def __init__(self, master, command, width=230, height=28): + super().__init__(master, width=width, height=height, bg=BG, + highlightthickness=0, cursor="hand2") + self._text = "Minecraft …" + self._cmd = command + self.bind("", lambda e: self._draw()) + self.bind("", lambda e: (self.configure(bg=HOVER), self._draw())) + self.bind("", lambda e: (self.configure(bg=BG), self._draw())) + self.bind("", lambda e: self._cmd and self._cmd()) + + def set_text(self, text): + self._text = text + self._draw() + + def _draw(self): + w = self.winfo_width() + if w < 40: + return + self.delete("all") + round_rect(self, 1, 1, w - 1, 27, 13, PILL, PILL_BD) + self.create_text(w // 2, 14, text=self._text, fill=DIM, + font=("Segoe UI", 9)) + + +class SlimProgress(tk.Canvas): + """Тонкая полоса прогресса по низу окна.""" + + def __init__(self, master, height=3): + super().__init__(master, height=height, bg=BAR, highlightthickness=0) + self.pct = 0.0 + self.bind("", lambda e: self._draw()) + + def set(self, pct): + self.pct = max(0.0, min(100.0, pct)) + self._draw() + + def _draw(self): + w = self.winfo_width() + self.delete("all") + if self.pct > 0 and w > 2: + self.create_rectangle(0, 0, max(2, w * self.pct / 100), 99, + fill=GREEN, outline="", tags="fill") + + +def _dark_titlebar(w): + """Тёмная полоса заголовка у системного окна (Windows 10 1809+).""" + if os.name != "nt": + return + try: + import ctypes + # 1 — тёмный титлбар, 0 — системный светлый (светлая тема) + val = ctypes.c_int(1 if THEME == "dark" else 0) + for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE + if ctypes.windll.dwmapi.DwmSetWindowAttribute( + w.winfo_id(), attr, ctypes.byref(val), + ctypes.sizeof(val)) == 0: + break + except Exception: # noqa: BLE001 + pass + + +# ============================================================ приложение +class App(tk.Tk): + def __init__(self, select_version=None, protocol_url=None, + select_profile=None): + super().__init__() + self.title("PixelLauncher") + self.overrideredirect(True) + self.geometry(f"{WIN_W}x{WIN_H}") + self.configure(bg=BG) + + self.settings = config.load_settings() + set_language(self.settings.get("lang", "ru")) + set_theme(self.settings.get("theme", "dark")) + set_accent(self.settings.get("accent", GREEN)) + self.configure(bg=BG) + self.ui_q = queue.Queue() + self.busy = False + self.versions = [] + self.ver_meta = {} + self._latest = {} + self._news = None + self._icon_img = None + self._head_img = None + self._head_after = None + self._log_lines = [] + self._menu = None + self._close_after_menu = False + self._pending_restore = False + self._saved_geom = None + self._cancel = None # threading.Event отмены загрузки + self._banner_after = None + self._log_txt_widget = None + self._log_view_mode = "session" # "session" или имя файла лога + self._log_orig_lines = [] # строки выбранного файла (для фильтра) + self._find_state = {"idx": -1, "n": 0} + self._force_version = select_version # версия из CLI (ярлык) + self._tray = None + self._update_active = False # идёт проверка/загрузка обновления + self._update_quiet = False # проверка при старте (без лишних окон) + self._base_scaling = float(self.tk.call("tk", "scaling")) + self._game_proc = None # процесс игры (для плагинов) + self._pending_import = None # .mrpack из pixellauncher:// + self.pl_q = queue.Queue() # UI-запросы плагинов/протокола + + # статистика игры и прокси — рядом с папкой игры + stats_mod.set_path(os.path.join(self._game_dir(), ".launcher")) + http.configure(self.settings.get("proxy")) + self._scene = self._pick_scene() + self._night = self._scene == "night" + self._apply_ui_scale() + + self._build() + self._set_icon() + self._enable_dnd() + self._load_settings_to_ui() + if select_profile: + self._apply_profile_at_start(select_profile) + self._center() + self.after(60, self._apply_taskbar_style) + self.after(100, self._poll_ui) + self.after(700, self._start_tray) + self._init_plugins() + self.after(800, self._proto_tick) + if protocol_url: + self.after(1200, lambda: self._start_protocol(protocol_url)) + self.after(6000, lambda: self._check_update(True)) # тихая проверка + self.bind("", self._on_map) + self.protocol("WM_DELETE_WINDOW", self._on_close) + threading.Thread(target=self._load_versions_bg, daemon=True).start() + nick = self.var_nick.get().strip() + if re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick): + threading.Thread(target=self._load_head_bg, args=(nick,), + daemon=True).start() + + # ------------------------------------------------------------ каркас + def _build(self): + # --- Строка окна (кастомный заголовок) + bar = tk.Frame(self, bg=BAR, height=44) + bar.pack(fill="x") + bar.pack_propagate(False) + left = tk.Frame(bar, bg=BAR) + left.pack(side="left", padx=(12, 0)) + self._logo(left, 22).pack(side="left", padx=(0, 8)) + tk.Label(left, text="PixelLauncher", bg=BAR, fg=TEXT, + font=("Segoe UI", 11, "bold")).pack(side="left") + right = tk.Frame(bar, bg=BAR) + right.pack(side="right", padx=(0, 4)) + self.lbl_nick = tk.Label(right, text=self.settings.get("username", "Player"), + bg=BAR, fg=TEXT, font=("Segoe UI", 10), + cursor="hand2") + self.lbl_nick.pack(side="left", padx=(0, 6)) + self.lbl_nick.bind("", lambda e: self._open_nick_menu()) + self.head_canvas = tk.Canvas(right, width=24, height=24, bg=BAR, + highlightthickness=0) + self.head_canvas.pack(side="left", padx=(0, 8)) + self._draw_head_placeholder() + Icon_label(right, "☾", lambda: self._set_night(True)).pack(side="left") + Icon_label(right, "☀", lambda: self._set_night(False)).pack(side="left") + Icon_label(right, "⚙", self._open_menu).pack(side="left") + Icon_label(right, "—", self._minimize).pack(side="left") + Icon_label(right, "✕", self._on_close, danger=True).pack(side="left") + tk.Frame(self, bg=BAR_BD, height=1).pack(fill="x") + + # перетаскивание окна за пустые места строки + self._make_draggable(left) + bar.bind("", self._drag_start) + bar.bind("", self._drag_move) + + # --- Основной контент + main = tk.Frame(self, bg=BG) + main.pack(fill="both", expand=True, pady=(26, 0)) + + self.banner = tk.Canvas(main, width=BANNER_W, height=BANNER_H, + bg=BG, highlightthickness=0) + self.banner.pack(fill="x") + self.banner.bind("", self._on_banner_configure) + + self.btn_play = PlayButton(main, self._on_play) + self.btn_play.pack(pady=(26, 0)) + + self.pill = VersionPill(main, self._open_versions) + self.pill.pack(pady=(14, 0)) + + # --- Лента новостей: последние версии из манифеста Mojang + self.news = tk.Frame(main, bg=BG) + self.news.pack(pady=(16, 0)) + tk.Label(self.news, text=T("ПОСЛЕДНИЕ ВЕРСИИ"), bg=BG, fg=FAINT, + font=("Segoe UI", 7, "bold")).pack() + self.news_rows = tk.Frame(self.news, bg=BG) + self.news_rows.pack() + self._fill_news(self._news) + + self.lbl_status = tk.Label(main, text=T("Готово к запуску"), bg=BG, fg=DIM, + font=("Segoe UI", 8)) + self.lbl_status.pack(pady=(12, 0)) + + # Кнопка отмены — видна только пока идёт загрузка + self.btn_cancel = tk.Button(main, text="■ " + T("ОТМЕНА"), + command=self._on_cancel, bg=PANEL, fg=ERR, + activebackground=HOVER, activeforeground=ERR, + relief="flat", bd=0, padx=12, pady=2, + cursor="hand2", font=("Segoe UI", 8, "bold")) + self.btn_cancel.pack(pady=(8, 0)) + self.btn_cancel.pack_forget() + + # --- Тонкий прогресс по низу + self.progress = SlimProgress(self) + self.progress.pack(side="bottom", fill="x") + + # --- Ручка изменения размера окна (окно без рамки) + grip = tk.Label(self, text="◢", bg=BAR, fg=FAINT, cursor="sizing", + width=2, font=("Segoe UI", 7)) + grip.place(relx=1.0, rely=1.0, anchor="se") + + def grip_start(e): + self._grip = (e.x_root, e.y_root, self.winfo_width(), + self.winfo_height()) + + def grip_move(e): + w = max(WIN_W, self._grip[2] + e.x_root - self._grip[0]) + h = max(WIN_H, self._grip[3] + e.y_root - self._grip[1]) + self.geometry(f"{w}x{h}") + + grip.bind("", grip_start) + grip.bind("", grip_move) + + def _on_banner_configure(self, _event=None): + """Отложенная перерисовка баннера при изменении размера окна.""" + if self._banner_after is not None: + self.after_cancel(self._banner_after) + self._banner_after = self.after(160, self._render_banner) + + def _render_banner(self, event=None): + if event is not None: # — перерисуем через паузу + self._on_banner_configure() + return + self._banner_after = None + w = self.banner.winfo_width() + h = self.banner.winfo_height() + if w < 40 or h < 40: + return + cv = self.banner + cv.delete("all") + draw_scene(cv, w, h, scene=self._scene) + # Пиксельный заголовок поверх пейзажа + scale = 6 + shadow = {"night": "#0d1420", "nether": "#2a0e0e", + "cave": "#14171a"}.get(self._scene, "#25331f") + draw_pixel_text(cv, 36, 34, "PIXEL", scale, "#ffffff", shadow, 3, 3) + draw_pixel_text(cv, 36, 90, "LAUNCHER", scale, "#ffffff", shadow, 3, 3) + round_mask(cv, w, h, 14, BG) + + def _set_night(self, night): + self._set_scene("night" if night else "day") + + def _pick_scene(self): + """Сцена баннера из настроек; «random» — случайная при каждом старте.""" + sc = self.settings.get("scene", "day") + if sc == "random": + return random.choice(("day", "night", "nether", "cave")) + return sc if sc in ("day", "night", "nether", "cave") else "day" + + def _set_scene(self, scene, save=False): + self._scene = scene if scene in ("day", "night", "nether", + "cave") else "day" + self._night = self._scene == "night" + if save: + self.settings["scene"] = scene + config.save_settings(self.settings) + self._render_banner() + names = {"day": T("Дневной пейзаж"), "night": T("Ночной пейзаж"), + "nether": T("Пейзаж Нижнего мира"), + "cave": T("Пещерный пейзаж")} + self._log(T("Сцена: ") + names.get(self._scene, self._scene), "ok") + + # ------------------------------------------------------------ окно + def _make_draggable(self, w): + """Вешает перетаскивание окна (только для безопасных виджетов).""" + w.bind("", self._drag_start) + w.bind("", self._drag_move) + for c in w.winfo_children(): + self._make_draggable(c) + + def _drag_start(self, e): + self._drag_dx = e.x_root - self.winfo_rootx() + self._drag_dy = e.y_root - self.winfo_rooty() + + def _drag_move(self, e): + self.geometry("+%d+%d" % (e.x_root - self._drag_dx, + e.y_root - self._drag_dy)) + + def _apply_taskbar_style(self): + """Окно без рамки должно иметь кнопку в панели задач.""" + if os.name != "nt": + return + try: + import ctypes + u32 = ctypes.windll.user32 + hwnd = u32.GetAncestor(self.winfo_id(), 2) # GA_ROOT + if not hwnd: + return + GWL_EXSTYLE, EX_APP, EX_TOOL = -20, 0x00040000, 0x00000080 + style = u32.GetWindowLongW(hwnd, GWL_EXSTYLE) + u32.SetWindowLongW(hwnd, GWL_EXSTYLE, + (style | EX_APP) & ~EX_TOOL) + except Exception: # noqa: BLE001 + pass + + def _minimize(self): + if os.name == "nt" and self._tray is not None: + self.withdraw() # сворачиваемся в трей (иконка уже там) + return + if os.name != "nt": + self.iconify() + return + try: + self._saved_geom = self.geometry() + self._pending_restore = True + self.overrideredirect(False) + self.iconify() + except tk.TclError: + self._pending_restore = False + + def _on_map(self, event): + if event.widget is not self or not self._pending_restore: + return + self._pending_restore = False + + def restore(): + try: + self.overrideredirect(True) + if self._saved_geom: + self.geometry(self._saved_geom) + self._apply_taskbar_style() + except tk.TclError: + pass + self.after(20, restore) + + def _logo(self, master, size): + c = tk.Canvas(master, width=size, height=size, bg=BAR, + highlightthickness=0) + u = max(1, size // 8) + pattern = ["gGgGgGgG", "gGgGgGgG", "dddddddd", "dDdDdddd", + "dddddddd", "dDddddDd", "dddddddd", "dDdddDdd"] + cmap = {"g": "#4f8f3f", "G": "#63a952", "d": "#6b4a2f", "D": "#7d5836"} + for ry, row in enumerate(pattern): + for rx, ch in enumerate(row): + c.create_rectangle(rx * u, ry * u, (rx + 1) * u, (ry + 1) * u, + fill=cmap[ch], outline="") + return c + + def _center(self): + self.update_idletasks() + sw, sh = self.winfo_screenwidth(), self.winfo_screenheight() + x = max(0, (sw - WIN_W) // 2) + y = max(0, (sh - WIN_H) // 2 - 20) + self.geometry(f"+{x}+{y}") + + def _set_icon(self): + try: + size = 32 + img = tk.PhotoImage(width=size, height=size) + u = size // 8 + # мини-версия логотипа (assets/make_logo.py, вариант gold): + # золотая P на тёмном слоте, травяной край, скруглённые углы + pattern = [".llllll.", ".pp..pp.", ".pp..pp.", ".pppppp.", + ".pp.....", ".pp.....", ".pp.....", ".Gggggg."] + cmap = {".": "#1b1f2b", "l": "#ffd98a", "p": "#ffb454", + "g": "#7cb342", "G": "#5a8f32"} + corners = {(0, 0), (7, 0), (0, 7), (7, 7)} + for ry, row in enumerate(pattern): + for rx, ch in enumerate(row): + if (rx, ry) in corners: + continue + img.put(cmap[ch], to=(rx * u, ry * u, (rx + 1) * u, (ry + 1) * u)) + self._icon_img = img + self.iconphoto(True, img) + except tk.TclError: + pass + + # ------------------------------------------------------------ голова + def _draw_head_placeholder(self): + c = self.head_canvas + c.delete("all") + u = 3 + pattern = ["hhhhhhhh", "hhhhhhhh", "hssssssh", "sswwbbss", + "ssssnnss", "ssmmmmss", "ssssssss", "ssssssss"] + cmap = {"h": "#2f241b", "s": "#b98a68", "w": "#f2f2f2", "b": "#4a6f9c", + "n": "#a97c5c", "m": "#7a5641"} + for ry, row in enumerate(pattern): + for rx, ch in enumerate(row): + c.create_rectangle(rx * u, ry * u, (rx + 1) * u, (ry + 1) * u, + fill=cmap[ch], outline="") + + def _load_head_bg(self, nick): + cache_dir = os.path.join(self._game_dir(), ".launcher", "heads") + path = os.path.join(cache_dir, nick + ".gif") + try: + # кэш живёт сутки, дальше перечитываем с mc-heads + fresh = (os.path.isfile(path) and + time.time() - os.path.getmtime(path) < 86400) + if not fresh: + from . import http + data = http.get_bytes(f"https://mc-heads.net/avatar/{nick}/24", + timeout=10) + os.makedirs(cache_dir, exist_ok=True) + with open(path, "wb") as f: + f.write(data) + except Exception: + if not os.path.isfile(path): # сети нет и кэша нет — плейсхолдер + return + if os.path.isfile(path): + self.ui_q.put(("head", nick, path)) + + def _show_head(self, nick, path): + if self.var_nick.get().strip() != nick: + return + try: + img = tk.PhotoImage(file=path) + self._head_img = img + self.head_canvas.delete("all") + self.head_canvas.create_image(0, 0, anchor="nw", image=img) + except tk.TclError: + self._draw_head_placeholder() + + # ------------------------------------------------------------ меню + def _open_nick_menu(self): + """Быстрое переключение аккаунтов по клику на ник.""" + m = tk.Menu(self, tearoff=0, bg=PANEL, fg=TEXT, + activebackground=HOVER, activeforeground=TEXT, + relief="flat", borderwidth=1, font=("Segoe UI", 9)) + active = self.settings.get("active_account") + for a in self.settings.get("accounts") or [active]: + mark = "● " if a == active else "" + m.add_command(label=mark + a, + command=lambda x=a: self._switch_account(x)) + m.add_separator() + m.add_command(label=T("Аккаунты…"), command=self._open_accounts) + self._menu = m + try: + m.tk_popup(self.lbl_nick.winfo_rootx(), + self.lbl_nick.winfo_rooty() + self.lbl_nick.winfo_height()) + except tk.TclError: + pass + finally: + self._menu = None + try: + m.grab_release() + except tk.TclError: + pass + + # ------------------------------------------------------------ плагины + def _init_plugins(self): + """Хуки API плагинов, сканирование папки, событие launcher_ready.""" + plugins_mod.bind_ui( + log=lambda m: self.ui_q.put(("log", m, None)), + message=lambda lvl, t, x: self.pl_q.put(("msg", lvl, t, x)), + ask_text=self._plugin_ask, + settings=lambda: self.settings, + game_dir=self._game_dir, + game_running=self._game_running, + ) + threading.Thread(target=plugins_mod.scan, daemon=True).start() + self.after(900, lambda: self._emit("launcher_ready")) + + def _emit(self, event, **kw): + try: + plugins_mod.emit(event, **kw) + except Exception: # noqa: BLE001 + pass + + def _game_running(self): + p = self._game_proc + return bool(p) and p.poll() is None + + def _plugin_ask(self, title, label, default=""): + """Синхронный ввод текста для плагина (из любого потока).""" + ev = threading.Event() + holder = [] + self.pl_q.put(("ask", title, label, default, ev, holder)) + if not ev.wait(timeout=120): + return None + return holder[0] if holder else None + + def _start_protocol(self, url): + """pixellauncher://… → скачивание в фоне, результат в pl_q.""" + parsed = protocol_mod.parse(url) + if parsed is None: + messagebox.showerror( + "PixelLauncher", + T("Неизвестная ссылка: ") + str(url), parent=self) + return + kind, target = parsed + + def bg(): + try: + if kind == "plugin": + plugins_mod.download_plugin(target) + plugins_mod.scan() + self.pl_q.put(("proto", "plugin", target, None)) + elif kind == "mrpack": + path = protocol_mod.fetch_mrpack(target) + self.pl_q.put(("proto", "mrpack", path, None)) + except Exception as e: # noqa: BLE001 + self.pl_q.put(("proto", None, None, str(e))) + + threading.Thread(target=bg, daemon=True).start() + + def _proto_tick(self): + """Очередь протокола + UI-запросы плагинов (каждые ~0.7 с).""" + self.after(700, self._proto_tick) + try: + while True: + msg = self.pl_q.get_nowait() + if msg[0] == "msg": + lvl, title, text = msg[1], msg[2], msg[3] + if lvl == "warn": + messagebox.showwarning(title, text, parent=self) + else: + messagebox.showinfo(title, text, parent=self) + elif msg[0] == "ask": + from tkinter import simpledialog + res = simpledialog.askstring( + msg[1], msg[2], parent=self, initialvalue=msg[3]) + msg[5].append(res) + msg[4].set() + elif msg[0] == "proto": + self._proto_result(msg[1], msg[2], msg[3]) + except queue.Empty: + pass + # ссылки от второго экземпляра лаунчера + for _, url in protocol_mod.queue_files(): + self._start_protocol(url) + # отложенный импорт модпака — когда лаунчер свободен + if self._pending_import and not self.busy: + path, self._pending_import = self._pending_import, None + self._import_pack(path=path) + + def _proto_result(self, kind, payload, err): + if err: + self._log(T("Протокол: ") + str(err), "err") + messagebox.showerror( + "PixelLauncher", + T("Не удалось выполнить ссылку: ") + str(err), + parent=self) + elif kind == "plugin": + self._log(T("Плагин установлен: ") + str(payload), "ok") + messagebox.showinfo( + "PixelLauncher", + T("Плагин установлен: ") + str(payload) + "\n" + + T("Он уже загружен."), parent=self) + elif kind == "mrpack": + self._pending_import = payload + + def _run_plugin_action(self, fn): + try: + fn() + except Exception as e: # noqa: BLE001 + self._log(T("Действие плагина: ошибка: ") + str(e), "err") + + def _open_plugins(self): + """Установленные плагины: список, пересканирование, удаление.""" + d = self._dialog(T("Плагины"), width=560) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + + lb = tk.Listbox(body, height=10, bg=INPUT, fg=TEXT, + selectbackground=GREEN, selectforeground="#0d1a0d", + relief="solid", bd=1, highlightthickness=0, + font=("Consolas", 9), exportselection=False) + lb.pack(fill="both", expand=True) + info = tk.Label(body, text="", bg=BG, fg=DIM, + font=("Segoe UI", 8), justify="left", anchor="w") + info.pack(fill="x", pady=(6, 0)) + entries = [] + + def refresh(): + lb.delete(0, "end") + entries[:] = [] + for p in plugins_mod.loaded(): + entries.append({"kind": "ok", "p": p}) + ver = (" (%s)" % p.version) if p.version else "" + lb.insert("end", "✓ " + p.file + ver + " — " + p.name) + for fname, err in plugins_mod.failures(): + entries.append({"kind": "err", "file": fname, "err": err}) + lb.insert("end", "✗ " + fname + " — " + err) + if not entries: + lb.insert("end", T("Плагинов нет. Загрузите из магазина.")) + info.configure(text=T("Папка плагинов: ") + plugins_mod.PLUGIN_DIR) + + def sel(): + s = lb.curselection() + if not s or s[0] >= len(entries): + return None + return entries[s[0]] + + def rescan(): + done = threading.Event() + + def go(): + plugins_mod.scan() + done.set() + + threading.Thread(target=go, daemon=True).start() + + def wait_done(): + if not d.winfo_exists(): + return + if done.is_set(): + refresh() + else: + d.after(150, wait_done) + + wait_done() + + def remove(): + e = sel() + if not e: + return + fname = e["p"].file if e["kind"] == "ok" else e["file"] + if messagebox.askyesno( + T("Плагины"), + T("Удалить плагин {f}?").format(f=fname), parent=d): + plugins_mod.remove_file(fname) + rescan() + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(10, 0)) + self._btn(btns, T("Пересканировать"), rescan, primary=True).pack( + side="left") + self._btn(btns, T("Удалить"), remove).pack(side="left", padx=(6, 0)) + self._btn(btns, T("Открыть папку"), + lambda: self._open_explorer(plugins_mod.PLUGIN_DIR)).pack( + side="left", padx=(6, 0)) + self._btn(btns, T("Магазин плагинов…"), self._open_store).pack( + side="left", padx=(6, 0)) + self._btn(btns, T("Закрыть"), d.destroy).pack(side="right") + refresh() + self._finish_dialog(d) + + def _open_store(self): + """Магазин плагинов: каталог с сайта и установка в один клик.""" + d = self._dialog(T("Магазин плагинов"), width=560) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + + status = tk.Label(body, text=T("Загрузка каталога…"), bg=BG, fg=DIM, + font=("Segoe UI", 8), anchor="w") + status.pack(fill="x") + lb = tk.Listbox(body, height=10, bg=INPUT, fg=TEXT, + selectbackground=GREEN, selectforeground="#0d1a0d", + relief="solid", bd=1, highlightthickness=0, + font=("Segoe UI", 9), exportselection=False) + lb.pack(fill="both", expand=True, pady=(6, 0)) + catalog = [] + + def fill(items): + nonlocal catalog + catalog = items or [] + have = set(plugins_mod.plugin_files()) + lb.delete(0, "end") + for it in catalog: + mark = "✓ " if str(it.get("file")) in have else "" + lb.insert("end", "%s%s — %s" + % (mark, it.get("title") or it.get("file"), + it.get("desc") or "")) + status.configure(text=T("Плагинов в каталоге: ") + + str(len(catalog)), fg=DIM) + + def load(): + status.configure(text=T("Загрузка каталога…"), fg=DIM) + box = {"cat": None, "err": None} + done = threading.Event() + + def go(): + try: + box["cat"] = plugins_mod.fetch_catalog() + except Exception as e: # noqa: BLE001 + box["err"] = str(e) + done.set() + + threading.Thread(target=go, daemon=True).start() + + def wait(): + if not d.winfo_exists(): + return + if not done.is_set(): + d.after(150, wait) + return + if box["err"]: + status.configure( + text=T("Каталог недоступен: ") + box["err"], fg=ERR) + else: + fill(box["cat"]) + + wait() + + def install(): + s = lb.curselection() + if not s or s[0] >= len(catalog): + return + fname = str(catalog[s[0]].get("file") or "") + btn_i.configure(state="disabled") + status.configure(text=T("Установка…"), fg=DIM) + res = {"err": None} + done = threading.Event() + + def go(): + try: + plugins_mod.download_plugin(fname) + plugins_mod.scan() + except Exception as e: # noqa: BLE001 + res["err"] = str(e) + done.set() + + threading.Thread(target=go, daemon=True).start() + + def wait(): + if not d.winfo_exists(): + return + if not done.is_set(): + d.after(150, wait) + return + btn_i.configure(state="normal") + if res["err"]: + status.configure(text=T("Ошибка: ") + res["err"], + fg=ERR) + else: + status.configure( + text=T("Плагин установлен: ") + fname, fg=OK) + fill(catalog) + + wait() + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(10, 0)) + btn_i = self._btn(btns, T("Установить"), install, primary=True) + btn_i.pack(side="left") + self._btn(btns, T("Обновить список"), load).pack( + side="left", padx=(6, 0)) + self._btn(btns, T("Закрыть"), d.destroy).pack(side="right") + load() + self._finish_dialog(d) + + def _open_packs(self): + """Каталог модпаков: список с сайта и установка в один клик.""" + d = self._dialog(T("Каталог модпаков"), width=580) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + + status = tk.Label(body, text=T("Загрузка каталога…"), bg=BG, fg=DIM, + font=("Segoe UI", 8), anchor="w") + status.pack(fill="x") + lb = tk.Listbox(body, height=10, bg=INPUT, fg=TEXT, + selectbackground=GREEN, selectforeground="#0d1a0d", + relief="solid", bd=1, highlightthickness=0, + font=("Segoe UI", 9), exportselection=False) + lb.pack(fill="both", expand=True, pady=(6, 0)) + catalog = [] + dl = {"cancel": None} + + def fill(items): + nonlocal catalog + catalog = items or [] + lb.delete(0, "end") + for it in catalog: + bits = [] + if it.get("mc"): + bits.append("MC " + str(it["mc"])) + if it.get("loader"): + bits.append(str(it["loader"])) + if it.get("size"): + bits.append(str(it["size"]) + " " + T("МБ")) + tail = (" [" + " · ".join(bits) + "]") if bits else "" + lb.insert("end", "%s — %s%s" + % (it.get("title") or "?", + it.get("desc") or "", tail)) + status.configure(text=T("Модпаков в каталоге: ") + + str(len(catalog)), fg=DIM) + + def load(): + status.configure(text=T("Загрузка каталога…"), fg=DIM) + box = {"cat": None, "err": None} + done = threading.Event() + + def go(): + try: + box["cat"] = mrpack_mod.fetch_catalog() + except Exception as e: # noqa: BLE001 + box["err"] = str(e) + done.set() + + threading.Thread(target=go, daemon=True).start() + + def wait(): + if not d.winfo_exists(): + return + if not done.is_set(): + d.after(150, wait) + return + if box["err"]: + status.configure( + text=T("Каталог недоступен: ") + box["err"], fg=ERR) + else: + fill(box["cat"]) + + wait() + + def install(): + s = lb.curselection() + if not s or s[0] >= len(catalog): + return + if self.busy: + status.configure( + text=T("Дождитесь окончания текущей загрузки"), fg=ERR) + return + entry = catalog[s[0]] + url = mrpack_mod.pack_url(entry) + btn_i.configure(state="disabled") + ev = dl["cancel"] = threading.Event() + box = {"err": None, "pct": -1, "path": None} + done = threading.Event() + + def go(): + try: + safe = (re.sub(r"[^\w.\-]+", "_", + str(entry.get("title") or "pack"))[:60] + or "pack") + dest = os.path.join(tempfile.gettempdir(), "PixelLauncher", + safe + ".mrpack") + os.makedirs(os.path.dirname(dest), exist_ok=True) + + def prog(done_b, total_b): + box["pct"] = (int(done_b * 100 / total_b) + if total_b else -1) + http.download_file(url, dest, progress=prog, cancel=ev) + box["path"] = dest + except http.Cancelled: + box["err"] = T("Загрузка отменена") + except Exception as e: # noqa: BLE001 + box["err"] = str(e) + done.set() + + threading.Thread(target=go, daemon=True).start() + + def wait(): + if not done.is_set(): + if not d.winfo_exists(): + return + if box["pct"] >= 0: + status.configure( + text=T("Скачивание: ") + str(box["pct"]) + "%", + fg=DIM) + d.after(200, wait) + return + if not d.winfo_exists(): + return + btn_i.configure(state="normal") + dl["cancel"] = None + if box["err"]: + status.configure(text=T("Ошибка: ") + box["err"], fg=ERR) + else: + path = box["path"] + d.destroy() + self._import_pack(path) # лоадер+файлы: главный прогресс + + wait() + + def close(): + if dl.get("cancel") is not None: + dl["cancel"].set() + d.destroy() + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(10, 0)) + btn_i = self._btn(btns, T("Установить"), install, primary=True) + btn_i.pack(side="left") + self._btn(btns, T("Обновить список"), load).pack( + side="left", padx=(6, 0)) + self._btn(btns, T("Закрыть"), close).pack(side="right") + d.protocol("WM_DELETE_WINDOW", close) + load() + self._finish_dialog(d) + + def _open_menu(self): + m = tk.Menu(self, tearoff=0, bg=PANEL, fg=TEXT, + activebackground=HOVER, activeforeground=TEXT, + relief="flat", borderwidth=1, font=("Segoe UI", 9)) + m.add_command(label=T("Профиль…"), command=self._open_profile) + m.add_command(label=T("Профили…"), command=self._open_profiles) + m.add_command(label=T("Аккаунты…"), command=self._open_accounts) + m.add_command(label=T("Выбрать версию…"), command=self._open_versions) + m.add_command(label=T("Установить лоадер…"), + command=self._open_fabric) + m.add_command(label=T("Моды…"), command=self._open_mods) + m.add_command(label=T("Импорт модпака…"), command=self._import_pack) + m.add_command(label=T("Каталог модпаков…"), command=self._open_packs) + m.add_command(label=T("Плагины…"), command=self._open_plugins) + m.add_command(label=T("Магазин плагинов…"), command=self._open_store) + m.add_command(label=T("Скин-превью…"), command=self._open_skin) + m.add_separator() + m.add_command(label=T("Резервная копия миров"), command=self._on_backup) + m.add_command(label=T("Миры и копии…"), command=self._open_worlds) + m.add_command(label=T("Скачать эту версию"), command=self._on_download) + m.add_command(label=T("Проверить файлы игры…"), command=self._on_verify) + m.add_command(label=T("Очистка версий…"), command=self._open_cleanup) + m.add_command(label=T("История и статистика…"), + command=self._open_history) + scene_menu = tk.Menu(m, tearoff=0, bg=PANEL, fg=TEXT, + activebackground=HOVER, activeforeground=TEXT, + relief="flat", borderwidth=1, + font=("Segoe UI", 9)) + for name, label in (("day", T("День")), ("night", T("Ночь")), + ("nether", T("Незер")), ("cave", T("Пещера")), + ("random", T("Случайно"))): + scene_menu.add_command( + label=("● " if self._scene == name else "") + label, + command=lambda n=name: self._set_scene(n, save=True)) + m.add_cascade(label=T("Сцена баннера"), menu=scene_menu) + m.add_command(label=T("Создать ярлык версии…"), + command=self._make_shortcut) + m.add_separator() + m.add_command(label=T("Открыть папку игры"), + command=lambda: self._open_explorer(self._game_dir())) + m.add_command(label=T("Открыть папку миров"), + command=lambda: self._open_explorer( + os.path.join(self._game_dir(), "saves"))) + m.add_command(label=T("Открыть скриншоты"), + command=lambda: self._open_explorer( + os.path.join(self._game_dir(), "screenshots"))) + m.add_command(label=T("Галерея скриншотов…"), + command=self._open_gallery) + m.add_separator() + m.add_command(label=T("Журнал…"), command=self._open_log) + m.add_cascade(label=T("Быстрые настройки"), + menu=self._quick_settings(m)) + m.add_command(label=T("Настройки…"), command=self._open_options) + m.add_command(label=T("Экспорт настроек…"), + command=self._export_settings) + m.add_command(label=T("Импорт настроек…"), + command=self._import_settings) + m.add_command(label=T("Проверить обновление лаунчера…"), + command=self._check_update) + m.add_command(label=T("О программе…"), command=self._open_about) + acts = plugins_mod.actions() + if acts: + m.add_separator() + for _label, _fn, _pname in acts: + m.add_command( + label=_label, + command=lambda f=_fn: self._run_plugin_action(f)) + self._menu = m + try: + m.tk_popup(self.winfo_rootx() + self.winfo_width() - 170, + self.winfo_rooty() + 44) + except tk.TclError: + pass + finally: + self._menu = None + try: + m.grab_release() + except tk.TclError: + pass + if self._close_after_menu: + self._close_after_menu = False + self.after(1, self.destroy) + + def _close_menu_if_open(self): + m = self._menu + if m is None: + return False + self._menu = None + try: + m.unpost() + except tk.TclError: + pass + try: + m.grab_release() + except tk.TclError: + pass + return True + + # ------------------------------------------------------------ диалоги + def _dialog(self, title, width=420): + d = tk.Toplevel(self) + d.title(title) + d.configure(bg=BG) + d.transient(self) + d.grab_set() + d.resizable(False, False) + d._pl_width = width + self.after(10, lambda: _dark_titlebar(d)) + return d + + def _finish_dialog(self, d): + d.update_idletasks() + width = getattr(d, "_pl_width", 420) + h = d.winfo_reqheight() + d.geometry(f"{width}x{h}") + d.update_idletasks() + x = self.winfo_rootx() + max(0, (self.winfo_width() - width) // 2) + y = self.winfo_rooty() + max(0, (self.winfo_height() - h) // 2) + d.geometry(f"+{x}+{y}") + + def _btn(self, master, text, command, primary=False): + b = tk.Button(master, text=text, command=command, + bg=GREEN if primary else PANEL, + fg="#ffffff" if primary else TEXT, + activebackground=GREEN_HI if primary else HOVER, + activeforeground="#ffffff", + relief="flat", bd=0, padx=14, pady=4, cursor="hand2", + font=("Segoe UI", 9, "bold" if primary else "normal")) + return b + + def _field(self, parent, label, var, width=32, browse=None): + row = tk.Frame(parent, bg=BG) + row.pack(fill="x", pady=3) + tk.Label(row, text=label, bg=BG, fg=DIM, width=14, anchor="w", + font=("Segoe UI", 9)).pack(side="left") + e = tk.Entry(row, textvariable=var, width=width, font=("Segoe UI", 9), + bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid", + bd=1, highlightbackground=INPUT_BD, highlightthickness=1) + e.pack(side="left", ipady=2) + e.bind("", lambda ev: e.configure(highlightbackground=GREEN)) + e.bind("", lambda ev: e.configure(highlightbackground=INPUT_BD)) + if browse: + self._btn(row, "…", browse).pack(side="left", padx=(4, 0)) + return e + + def _hint(self, parent, text, indent=108): + row = tk.Frame(parent, bg=BG) + row.pack(fill="x", pady=(1, 3)) + tk.Frame(row, width=indent, bg=BG).pack(side="left") + tk.Label(row, text=text, bg=BG, fg=FAINT, anchor="w", + font=("Segoe UI", 8)).pack(side="left", fill="x") + + def _open_profile(self): + d = self._dialog(T("Профиль игрока")) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + + var_nick = tk.StringVar(value=self.var_nick.get()) + self._field(body, T("Ник:"), var_nick, 20) + + ram_frame = tk.Frame(body, bg=BG) + ram_frame.pack(fill="x", pady=3) + tk.Label(ram_frame, text=T("ОЗУ (МБ):"), bg=BG, fg=DIM, width=14, + anchor="w", font=("Segoe UI", 9)).pack(side="left") + var_ram = tk.IntVar(value=self.settings.get("ram", 2048)) + var_auto = tk.BooleanVar(value=bool(self.settings.get("auto_ram"))) + scale = tk.Scale(ram_frame, from_=1024, to=self._ram_max(), + resolution=512, orient="horizontal", variable=var_ram, + bg=BG, fg=TEXT, troughcolor=INPUT, highlightthickness=0, + length=200, font=("Segoe UI", 8)) + scale.pack(side="left") + + def toggle_auto(): + scale.configure(state="disabled" if var_auto.get() else "normal") + tk.Checkbutton(ram_frame, text=T("Авто (по памяти системы)"), + variable=var_auto, command=toggle_auto, bg=BG, fg=DIM, + selectcolor=INPUT, activebackground=BG, activeforeground=TEXT, + highlightthickness=0, font=("Segoe UI", 8)).pack(side="left", + padx=(8, 0)) + if var_auto.get(): + toggle_auto() + + var_java = tk.StringVar(value=self.settings.get("java_path", "")) + self._field(body, T("Java:"), var_java, 30, + browse=lambda: self._pick_java_file(var_java)) + self._hint(body, T("Пусто = подбор Java автоматически.")) + + var_jvm = tk.StringVar(value=self.settings.get("jvm_args", "")) + self._field(body, T("JVM-аргументы:"), var_jvm, 30) + + # --- Пресет JVM (заполняет поле аргументов готовым набором) + presets = { + "": (T("(нет)"), ""), + "perf": (T("Производительность (1.16+)"), + "-XX:+UseG1GC -XX:+ParallelRefProcEnabled " + "-XX:MaxGCPauseMillis=200 -XX:+UnlockExperimentalVMOptions" + " -XX:+DisableExplicitGC -XX:+AlwaysPreTouch"), + "old": (T("Старые версии (Java 8)"), + "-XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled " + "-XX:PermSize=256m -XX:MaxPermSize=512m"), + } + pre_row = tk.Frame(body, bg=BG) + pre_row.pack(fill="x", pady=3) + tk.Label(pre_row, text=T("Пресет JVM:"), bg=BG, fg=DIM, width=14, + anchor="w", font=("Segoe UI", 9)).pack(side="left") + var_preset = tk.StringVar(value=presets.get( + self.settings.get("jvm_preset", ""), presets[""])[0]) + cb_preset = ttk.Combobox(pre_row, textvariable=var_preset, width=28, + state="readonly", + values=[v[0] for v in presets.values()]) + cb_preset.pack(side="left") + + def apply_preset(_e=None): + for key, (label, args) in presets.items(): + if label == var_preset.get(): + var_jvm.set(args) + var_preset_key[0] = key + break + var_preset_key = [self.settings.get("jvm_preset", "") + if self.settings.get("jvm_preset", "") in presets + else ""] + cb_preset.bind("<>", apply_preset) + + # --- Окно игры: разрешение и полный экран + win_row = tk.Frame(body, bg=BG) + win_row.pack(fill="x", pady=3) + tk.Label(win_row, text=T("Окно игры:"), bg=BG, fg=DIM, width=14, + anchor="w", font=("Segoe UI", 9)).pack(side="left") + var_ww = tk.StringVar(value=str(self.settings.get("win_w") or "")) + var_wh = tk.StringVar(value=str(self.settings.get("win_h") or "")) + tk.Label(win_row, text=T("Ширина:"), bg=BG, fg=FAINT, + font=("Segoe UI", 8)).pack(side="left") + tk.Entry(win_row, textvariable=var_ww, width=6, font=("Segoe UI", 9), + bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid", + bd=1, highlightthickness=0).pack(side="left", padx=(3, 8)) + tk.Label(win_row, text=T("Высота:"), bg=BG, fg=FAINT, + font=("Segoe UI", 8)).pack(side="left") + tk.Entry(win_row, textvariable=var_wh, width=6, font=("Segoe UI", 9), + bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid", + bd=1, highlightthickness=0).pack(side="left", padx=(3, 8)) + var_fs = tk.BooleanVar(value=bool(self.settings.get("fullscreen"))) + tk.Checkbutton(win_row, text=T("Полный экран"), variable=var_fs, + bg=BG, fg=DIM, selectcolor=INPUT, activebackground=BG, + activeforeground=TEXT, highlightthickness=0, + font=("Segoe UI", 8)).pack(side="left") + self._hint(body, T("0 или пусто = как в игре.")) + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(12, 0)) + + def save(): + nick = var_nick.get().strip() + if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick): + messagebox.showwarning("PixelLauncher", + T("Ник: 3-16 символов, латиница, цифры и _"), + parent=d) + return + try: + ww = int(var_ww.get().strip() or 0) + wh = int(var_wh.get().strip() or 0) + if ww < 0 or wh < 0 or ww > 10000 or wh > 10000: + raise ValueError + except ValueError: + messagebox.showwarning("PixelLauncher", + T("0 или пусто = как в игре."), + parent=d) + return + self._rename_active_account(nick) + self.settings.update({ + "username": nick, "ram": int(var_ram.get()), + "auto_ram": bool(var_auto.get()), + "java_path": var_java.get().strip(), + "jvm_args": var_jvm.get().strip(), + "jvm_preset": var_preset_key[0], + "win_w": ww, "win_h": wh, + "fullscreen": bool(var_fs.get()), + }) + config.save_settings(self.settings) + self.var_nick.set(nick) + self.lbl_nick.configure(text=nick) + self._log(T("Профиль сохранён: ") + nick, "ok") + threading.Thread(target=self._load_head_bg, args=(nick,), + daemon=True).start() + d.destroy() + + self._btn(btns, T("Скин-превью…"), self._open_skin).pack(side="left") + self._btn(btns, T("Сохранить"), save, primary=True).pack(side="right") + self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6)) + d.bind("", lambda e: save()) + self._finish_dialog(d) + + def _open_profiles(self): + """Список профилей запуска: применить, создать, изменить, удалить.""" + d = self._dialog(T("Профили"), width=660) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + + lb = tk.Listbox(body, height=10, bg=INPUT, fg=TEXT, + selectbackground=GREEN, selectforeground="#0d1a0d", + relief="solid", bd=1, highlightthickness=0, + font=("Segoe UI", 9), exportselection=False) + lb.pack(fill="both", expand=True) + lbl_hint = tk.Label(body, text="", bg=BG, fg=FAINT, anchor="w", + font=("Segoe UI", 8)) + lbl_hint.pack(fill="x", pady=(4, 0)) + + def profiles(): + return list(self.settings.get("profiles") or []) + + def refresh(): + lb.delete(0, "end") + ps = profiles() + active = self.settings.get("active_profile") or "" + for p in ps: + mods_n = "" + if isinstance(p.get("mods"), list): + mods_n = f" · {T('Моды')}: {len(p['mods'])}" + lb.insert("end", ("● " if p.get("name") == active else " ") + + str(p.get("name", "?")) + " — " + + str(p.get("username", "")) + " · " + + str(p.get("version", "")) + mods_n) + lbl_hint.configure(text=T("Список профилей пуст") if not ps else "") + if ps: + lb.selection_set(0) + + def after_edit(): + refresh() + try: + d.grab_set() + except tk.TclError: + pass + + def selected(): + sel = lb.curselection() + ps = profiles() + if not sel or sel[0] >= len(ps): + return None + return ps[sel[0]] + + def apply(): + p = selected() + if not p: + messagebox.showinfo("PixelLauncher", T("Выберите профиль"), + parent=d) + return + self.apply_profile(p) + refresh() + + def make_shortcut(): + p = selected() + if not p: + messagebox.showinfo("PixelLauncher", T("Выберите профиль"), + parent=d) + return + self._make_shortcut(p) + refresh() + + def clear_profile(): + self.settings["active_profile"] = "" + config.save_settings(self.settings) + self._update_pill() + self._log(T("Без профиля"), None) + refresh() + + def delete_profile(): + p = selected() + if not p: + return + if not messagebox.askyesno( + "PixelLauncher", + T("Удалить профиль {p}?").format(p=p.get("name", "")), + parent=d): + return + self.settings["profiles"] = [x for x in profiles() if x is not p] + if self.settings.get("active_profile") == p.get("name"): + self.settings["active_profile"] = "" + self._update_pill() + config.save_settings(self.settings) + self._log(T("Профиль удалён: ") + str(p.get("name", "")), "ok") + refresh() + + lb.bind("", lambda e: apply()) + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(10, 0)) + self._btn(btns, T("Применить"), apply, primary=True).pack(side="right") + self._btn(btns, T("Закрыть"), d.destroy).pack(side="right", + padx=(0, 6)) + self._btn(btns, T("Без профиля"), clear_profile).pack(side="left") + self._btn(btns, T("Удалить"), delete_profile).pack(side="left", + padx=(6, 0)) + self._btn(btns, T("Изменить"), + lambda: selected() and self._profile_editor( + d, selected(), after_edit)).pack(side="left", padx=(6, 0)) + self._btn(btns, T("Новый профиль"), + lambda: self._profile_editor(d, None, after_edit)).pack( + side="left", padx=(6, 0)) + self._btn(btns, T("Ярлык"), make_shortcut).pack(side="left", + padx=(6, 0)) + refresh() + self._finish_dialog(d) + return d + + def _profile_editor(self, parent, p, on_done): + """Форма создания/изменения профиля запуска. p=None — новый.""" + e = self._dialog(T("Профиль"), width=430) + try: + e.transient(parent) + except tk.TclError: + pass + body = tk.Frame(e, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + cur = p or {} + var_name = tk.StringVar(value=str(cur.get("name", ""))) + self._field(body, T("Имя:"), var_name, 22) + var_nick = tk.StringVar( + value=str(cur.get("username") or self.var_nick.get())) + self._field(body, T("Ник:"), var_nick, 22) + var_ver = tk.StringVar( + value=str(cur.get("version") or self.var_ver.get())) + self._field(body, T("Версия:"), var_ver, 22) + var_ram = tk.StringVar(value=str( + cur.get("ram") or self.settings.get("ram", 2048))) + self._field(body, T("ОЗУ (МБ):"), var_ram, 8) + var_fs = tk.BooleanVar(value=bool( + cur.get("fullscreen", self.settings.get("fullscreen")))) + tk.Checkbutton(body, text=T("Полный экран"), variable=var_fs, bg=BG, + fg=DIM, selectcolor=INPUT, activebackground=BG, + activeforeground=TEXT, highlightthickness=0, + font=("Segoe UI", 9)).pack(anchor="w", pady=3) + var_mods = tk.BooleanVar(value=isinstance(cur.get("mods"), list)) + tk.Checkbutton(body, text=T("Запомнить включённые моды"), + variable=var_mods, bg=BG, fg=DIM, selectcolor=INPUT, + activebackground=BG, activeforeground=TEXT, + highlightthickness=0, + font=("Segoe UI", 9)).pack(anchor="w", pady=3) + self._hint(body, T("Профиль хранит ник, версию, ОЗУ и по желанию " + "набор включённых модов."), indent=0) + + def save(): + name = var_name.get().strip() + nick = var_nick.get().strip() + ver = var_ver.get().strip() + if not name: + messagebox.showwarning("PixelLauncher", T("Имя:"), parent=e) + return + if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick): + messagebox.showwarning( + "PixelLauncher", + T("Ник: 3-16 символов, латиница, цифры и _"), parent=e) + return + if not ver: + messagebox.showwarning("PixelLauncher", T("Версия:"), + parent=e) + return + try: + ram = int(var_ram.get().strip() or 2048) + if ram < 256 or ram > 65536: + raise ValueError + except ValueError: + messagebox.showwarning("PixelLauncher", T("ОЗУ (МБ):"), + parent=e) + return + mods = None + if var_mods.get(): + mods = [m["file"] for m in mods_mod.list_mods(self._game_dir()) + if m["enabled"]] + prof = {"name": name, "username": nick, "version": ver, + "ram": ram, "fullscreen": bool(var_fs.get()), "mods": mods, + "win_w": int(self.settings.get("win_w") or 0), + "win_h": int(self.settings.get("win_h") or 0), + "java_path": str(self.settings.get("java_path") or ""), + "jvm_args": str(self.settings.get("jvm_args") or ""), + "jvm_preset": str(self.settings.get("jvm_preset") or ""), + "auto_ram": bool(self.settings.get("auto_ram"))} + ps = [x for x in (self.settings.get("profiles") or []) + if x is not p] + ps.append(prof) + ps.sort(key=lambda x: str(x.get("name", "")).lower()) + self.settings["profiles"] = ps + config.save_settings(self.settings) + e.destroy() + on_done() + + self._btn(body, T("Сохранить"), save, primary=True).pack(anchor="e", + pady=(10, 0)) + e.bind("", lambda ev: save()) + self._finish_dialog(e) + return e + + def apply_profile(self, p): + """Применяет профиль запуска: ник, версию, ОЗУ, окно, набор модов.""" + old_prof = str(self.settings.get("active_profile") or "") + old_acc = self.settings.get("active_account") + nick = str(p.get("username") or "").strip() + if nick: + accs = list(self.settings.get("accounts") or []) + if nick in accs: + self.settings["active_account"] = nick + else: + self._rename_active_account(nick) + self.settings["username"] = nick + self.var_nick.set(nick) + self.lbl_nick.configure(text=nick) + if nick != old_acc: + self._emit("account_switched", username=nick) + ver = str(p.get("version") or "").strip() + if ver: + self.var_ver.set(ver) + for k in ("ram", "auto_ram", "fullscreen", "win_w", "win_h", + "java_path", "jvm_args", "jvm_preset"): + if k in p: + self.settings[k] = p[k] + self.settings["version"] = self.var_ver.get().strip() + mods = p.get("mods") + if isinstance(mods, list): + def base_of(fn): + low = str(fn).lower() + if low.endswith(".disabled"): + low = low[:-len(".disabled")] + if low.endswith(".jar"): + low = low[:-4] + return low + want = {base_of(x) for x in mods} + for m in mods_mod.list_mods(self._game_dir()): + on = base_of(m["file"]) in want + if m["enabled"] != on: + mods_mod.set_enabled(self._game_dir(), m["file"], on) + new_prof = str(p.get("name") or "") + self.settings["active_profile"] = new_prof + config.save_settings(self.settings) + if new_prof and new_prof != old_prof: + self._emit("profile_switched", profile_name=new_prof) + self._update_pill() + self._log(T("Профиль применён: ") + self.settings["active_profile"], + "ok") + + def _apply_profile_at_start(self, name): + """Профиль из CLI (ярлык рабочего стола): применяем, если найден.""" + for p in self.settings.get("profiles") or []: + if str(p.get("name") or "") == str(name): + self.apply_profile(p) + ver = str(p.get("version") or "").strip() + if ver: + # версию выбираем после загрузки списка (см. _load_versions) + self._force_version = ver + return + self._log(T("Профиль не найден: ") + str(name), "warn") + + def _open_worlds(self): + """Список миров: копия по кнопке, удаление копий и миров.""" + d = self._dialog(T("Миры и копии"), width=620) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + + cols = tk.Frame(body, bg=BG) + cols.pack(fill="both", expand=True) + left = tk.Frame(cols, bg=BG) + left.pack(side="left", fill="both", expand=True) + right = tk.Frame(cols, bg=BG) + right.pack(side="left", fill="both", expand=True, padx=(10, 0)) + + tk.Label(left, text=T("Миры"), bg=BG, fg=TEXT, + font=("Segoe UI", 9, "bold")).pack(anchor="w") + lb_w = tk.Listbox(left, height=10, bg=INPUT, fg=TEXT, + selectbackground=GREEN, selectforeground="#0d1a0d", + relief="solid", bd=1, highlightthickness=0, + font=("Consolas", 9), exportselection=False) + lb_w.pack(fill="both", expand=True, pady=(4, 0)) + tk.Label(right, text=T("Копии"), bg=BG, fg=TEXT, + font=("Segoe UI", 9, "bold")).pack(anchor="w") + lb_b = tk.Listbox(right, height=10, bg=INPUT, fg=TEXT, + selectbackground=GREEN, selectforeground="#0d1a0d", + relief="solid", bd=1, highlightthickness=0, + font=("Consolas", 9), exportselection=False) + lb_b.pack(fill="both", expand=True, pady=(4, 0)) + + lq = queue.Queue() + + def poll(): + if not d.winfo_exists(): + return + try: + while True: + msg = lq.get_nowait() + if msg[0] == "log": + self._log(msg[1], msg[2] if len(msg) > 2 else None) + elif msg[0] == "refresh": + refresh_all() + except queue.Empty: + pass + d.after(120, poll) + d.after(120, poll) + + worlds, backups = [], [] + + def sel_world(): + sel = lb_w.curselection() + if not sel or sel[0] >= len(worlds): + return None + return worlds[sel[0]] + + def sel_backup(): + sel = lb_b.curselection() + if not sel or sel[0] >= len(backups): + return None + return backups[sel[0]] + + def refresh_worlds(): + lb_w.delete(0, "end") + worlds[:] = worlds_mod.list_worlds(self._game_dir()) + for w in worlds: + lb_w.insert("end", f"{w['name']} " + f"({mods_mod.fmt_size(w['size'])}, " + f"{stats_mod.fmt_time(w['mtime'])})") + if worlds: + lb_w.selection_set(0) + + def refresh_backups(): + lb_b.delete(0, "end") + backups[:] = [] + w = sel_world() + if w: + backups[:] = worlds_mod.list_backups(self._game_dir(), w["name"]) + for b in backups: + lb_b.insert("end", f"{b['name']} " + f"({mods_mod.fmt_size(b['size'])})") + if not w: + lb_b.insert("end", T("Выберите мир")) + + def refresh_all(): + refresh_worlds() + refresh_backups() + + refresh_all() + lb_w.bind("<>", lambda e: refresh_backups()) + + def make_backup(): + w = sel_world() + if not w: + messagebox.showinfo("PixelLauncher", T("Выберите мир"), + parent=d) + return + + def bg(): + try: + cap = int(self.settings.get("world_backups", 5)) + path = worlds_mod.backup_world( + self._game_dir(), w["name"], cap, + log=lambda m: lq.put(("log", m))) + lq.put(("log", T("Копия мира {w} создана: ").format( + w=w["name"]) + os.path.basename(path), "ok")) + except Exception as err: # noqa: BLE001 + lq.put(("log", T("Ошибка: ") + str(err), "err")) + lq.put(("refresh",)) + threading.Thread(target=bg, daemon=True).start() + + def delete_backup(): + b = sel_backup() + w = sel_world() + if not b or not w: + return + if not messagebox.askyesno( + "PixelLauncher", + T("Удалить копию {b}?").format(b=b["name"]), + parent=d): + return + if worlds_mod.remove_backup(self._game_dir(), w["name"], + b["name"]): + self._log(T("Копия удалена: ") + b["name"], "ok") + refresh_backups() + + def open_folder(): + w = sel_world() + if not w: + return + path = worlds_mod.backups_dir(self._game_dir(), w["name"]) + os.makedirs(path, exist_ok=True) + self._open_explorer(path) + + def delete_world(): + w = sel_world() + if not w: + return + if not messagebox.askyesno( + "PixelLauncher", + T("Удалить мир {w}? Это нельзя отменить.").format( + w=w["name"]), parent=d): + return + if worlds_mod.remove_world(self._game_dir(), w["name"]): + self._log(T("Мир удалён: ") + w["name"], "ok") + refresh_all() + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(10, 0)) + self._btn(btns, T("Закрыть"), d.destroy).pack(side="right") + self._btn(btns, T("Открыть папку копий"), open_folder).pack( + side="right", padx=(0, 6)) + self._btn(btns, T("Удалить мир"), delete_world).pack(side="left") + self._btn(btns, T("Удалить копию"), delete_backup).pack( + side="left", padx=(6, 0)) + self._btn(btns, T("Создать копию"), make_backup, primary=True).pack( + side="left", padx=(6, 0)) + self._finish_dialog(d) + return d + + def _rename_active_account(self, nick): + """Переименовывает активный аккаунт в списке (ник в профиле = аккаунт).""" + accs = list(self.settings.get("accounts") or []) + old = self.settings.get("active_account") + if old in accs: + accs[accs.index(old)] = nick + elif nick not in accs: + accs.append(nick) + seen, out = set(), [] + for a in accs: + if a not in seen: + seen.add(a) + out.append(a) + self.settings["accounts"] = out + self.settings["active_account"] = nick + + def _open_versions(self): + d = self._dialog(T("Выбор версии"), width=470) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + + var_q = tk.StringVar() + e = tk.Entry(body, textvariable=var_q, font=("Segoe UI", 9), + bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid", + bd=1, highlightbackground=INPUT_BD, highlightthickness=1) + e.pack(fill="x", ipady=2) + e.focus_set() + + lb = tk.Listbox(body, height=16, bg=INPUT, fg=TEXT, selectbackground=GREEN, + selectforeground="#0d1a0d", relief="solid", bd=1, + highlightthickness=0, font=("Consolas", 9), + exportselection=False) + lb.pack(fill="both", expand=True, pady=(8, 0)) + tk.Label(body, text=T("★ — закрепить версию, двойной клик — выбрать"), + bg=BG, fg=FAINT, font=("Segoe UI", 8)).pack(anchor="w", + pady=(4, 0)) + + counts = {} # сколько раз запускалась версия — частые сверху + try: + for r in stats_mod.totals().get("per_version") or []: + counts[r["ver"]] = int(r.get("count") or 0) + except Exception: # noqa: BLE001 + pass + + var_only_fav = tk.BooleanVar(value=False) + rows = [] + + def refresh(*_args): + # trace_add вызывает колбэк с 3 аргументами (имя, индекс, оп) + q = var_q.get().strip().lower() + favs = set(self.settings.get("fav_versions") or []) + items = [] + for idx, v in enumerate(self.versions): + if q and q not in v.lower(): + continue + base = v.split(" ")[0] + vtype, date = self.ver_meta.get(base, ("", "")) + mark = {"release": "", "snapshot": T("[снапшот]"), + "local": T("[локальная]")}.get(vtype, "") + star = "★ " if base in favs else " " + items.append([0 if base in favs else 1, + -counts.get(base, 0), idx, + v, f"{star}{v:<26} {date} {mark}"]) + if var_only_fav.get(): + items = [it for it in items if it[0] == 0] + items.sort(key=lambda it: (it[0], it[1], it[2])) + lb.delete(0, "end") + rows.clear() + for it in items: + lb.insert("end", it[4]) + rows.append(it[3]) + if rows: + lb.selection_set(0) + + var_q.trace_add("write", refresh) + var_only_fav.trace_add("write", refresh) + refresh() + + def selected_base(): + sel = lb.curselection() + if not sel or sel[0] >= len(rows): + return None + return rows[sel[0]].split(" ")[0] + + def toggle_fav(): + base = selected_base() + if not base: + return + favs = list(self.settings.get("fav_versions") or []) + if base in favs: + favs.remove(base) + else: + favs.append(base) + self.settings["fav_versions"] = favs + config.save_settings(self.settings) + refresh() + + def choose(_=None): + sel = lb.curselection() + if not sel or sel[0] >= len(rows): + return + self._pick_version(rows[sel[0]]) + d.destroy() + + lb.bind("", choose) + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(10, 0)) + self._btn(btns, T("Выбрать"), choose, primary=True).pack(side="right") + self._btn(btns, T("Обновить список"), + lambda: (self._reload_versions(), d.destroy())).pack(side="right", + padx=(0, 6)) + self._btn(btns, T("★ Закрепить/убрать"), toggle_fav).pack(side="left") + tk.Checkbutton(btns, text=T("Только избранные"), variable=var_only_fav, + bg=BG, fg=DIM, selectcolor=INPUT, activebackground=BG, + activeforeground=TEXT, highlightthickness=0, + font=("Segoe UI", 9)).pack(side="left", padx=(8, 0)) + d.bind("", lambda e: choose()) + self._finish_dialog(d) + return d + + def _quick_settings(self, parent): + """Подменю быстрых настроек в главном меню. + + Дублирует чекбоксы и выборы диалога «Настройки» (тема, автозагрузка, + трей, авто-бэкап, масштаб, язык): переключение мгновенное и с + сохранением — не нужно открывать настройки ради одного пункта. + Пересборку окна откладываем через after_idle: меню к этому моменту + уже закрыто, а _rebuild_ui уничтожает все виджеты главного окна. + """ + def sub(): + return tk.Menu(parent, tearoff=0, bg=PANEL, fg=TEXT, + activebackground=HOVER, activeforeground=TEXT, + relief="flat", borderwidth=1, + font=("Segoe UI", 9)) + + qs = sub() + qs.configure(selectcolor=INPUT) + + var_light = tk.BooleanVar(value=self.settings.get("theme") == "light") + + def t_light(): + self.settings["theme"] = "light" if var_light.get() else "dark" + config.save_settings(self.settings) + set_theme(self.settings["theme"]) + self.after_idle(self._rebuild_ui) + + qs.add_checkbutton(label=T("Светлая тема"), variable=var_light, + command=t_light) + + var_auto = tk.BooleanVar(value=bool(self.settings.get("autostart"))) + + def t_auto(): + if not self._set_autostart(bool(var_auto.get())): + var_auto.set(not var_auto.get()) # сбой — вернуть флажок + return + self.settings["autostart"] = bool(var_auto.get()) + config.save_settings(self.settings) + + qs.add_checkbutton(label=T("С автозагрузкой Windows"), + variable=var_auto, command=t_auto) + + var_tray = tk.BooleanVar( + value=bool(self.settings.get("minimize_to_tray_on_launch"))) + + def t_tray(): + self.settings["minimize_to_tray_on_launch"] = bool(var_tray.get()) + config.save_settings(self.settings) + self._log(T("Настройки сохранены"), "ok") + + qs.add_checkbutton(label=T("Прятать в трей при запуске игры"), + variable=var_tray, command=t_tray) + + var_bak = tk.BooleanVar(value=bool(self.settings.get("auto_backup"))) + + def t_bak(): + self.settings["auto_backup"] = bool(var_bak.get()) + config.save_settings(self.settings) + self._log(T("Настройки сохранены"), "ok") + + qs.add_checkbutton(label=T("Авто-бэкап мира перед запуском"), + variable=var_bak, command=t_bak) + + cur_scale = int(float(self.settings.get("ui_scale", 1.0)) * 100) + sc_menu = sub() + for pct in (100, 125, 150): + sc_menu.add_command( + label=("● " if pct == cur_scale else "") + f"{pct}%", + command=lambda p=pct: self._quick_scale(p)) + qs.add_cascade(label=T("Масштаб интерфейса:"), menu=sc_menu) + + cur_lang = self.settings.get("lang", "ru") + lang_menu = sub() + for code, name in (("ru", "Русский"), ("en", "English")): + lang_menu.add_command( + label=("● " if code == cur_lang else "") + name, + command=lambda c=code: self._quick_lang(c)) + qs.add_cascade(label=T("Язык:"), menu=lang_menu) + return qs + + def _quick_scale(self, pct): + """Масштаб интерфейса из быстрого меню — сразу и с сохранением.""" + self.settings["ui_scale"] = pct / 100 + config.save_settings(self.settings) + self._apply_ui_scale() + self.after_idle(self._rebuild_ui) + + def _quick_lang(self, code): + """Язык интерфейса из быстрого меню — сразу и с сохранением.""" + if self.settings.get("lang") == code: + return + self.settings["lang"] = code + config.save_settings(self.settings) + set_language(code) + self.after_idle(self._rebuild_ui) + + def _open_options(self): + d = self._dialog(T("Настройки"), width=640) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + var_dir = tk.StringVar(value=self._game_dir()) + self._field(body, T("Папка игры:"), var_dir, 34, + browse=lambda: self._pick_dir(var_dir)) + self._hint(body, T("Здесь хранятся .minecraft, миры и файлы игры.")) + + row = tk.Frame(body, bg=BG) + row.pack(fill="x", pady=(10, 0)) + self._btn(row, T("Открыть папку игры"), + lambda: self._open_explorer(self._game_dir())).pack(side="left") + self._btn(row, T("Открыть папку миров"), + lambda: self._open_explorer( + os.path.join(self._game_dir(), "saves"))).pack(side="left", + padx=(6, 0)) + self._btn(row, T("Открыть скриншоты"), + lambda: self._open_explorer( + os.path.join(self._game_dir(), "screenshots"))).pack( + side="left", padx=(6, 0)) + self._btn(row, T("Галерея"), + self._open_gallery).pack(side="left", padx=(6, 0)) + + # --- Язык + lang_row = tk.Frame(body, bg=BG) + lang_row.pack(fill="x", pady=(12, 0)) + tk.Label(lang_row, text=T("Язык:"), bg=BG, fg=DIM, width=14, + anchor="w", font=("Segoe UI", 9)).pack(side="left") + var_lang = tk.StringVar(value=self.settings.get("lang", "ru")) + for code, name in (("ru", "Русский"), ("en", "English")): + tk.Radiobutton(lang_row, text=name, variable=var_lang, value=code, + bg=BG, fg=TEXT, selectcolor=INPUT, indicatoron=True, + activebackground=BG, activeforeground=TEXT, + highlightthickness=0, + font=("Segoe UI", 9)).pack(side="left", padx=(0, 10)) + + # --- Тема оформления + th_row = tk.Frame(body, bg=BG) + th_row.pack(fill="x", pady=(8, 0)) + tk.Label(th_row, text=T("Тема:"), bg=BG, fg=DIM, width=14, + anchor="w", font=("Segoe UI", 9)).pack(side="left") + var_theme = tk.StringVar(value=self.settings.get("theme", "dark")) + for th_code, th_name in (("dark", T("Тёмная")), + ("light", T("Светлая"))): + tk.Radiobutton(th_row, text=th_name, variable=var_theme, + value=th_code, bg=BG, fg=TEXT, selectcolor=INPUT, + indicatoron=True, activebackground=BG, + activeforeground=TEXT, highlightthickness=0, + font=("Segoe UI", 9)).pack(side="left", padx=(0, 10)) + + # --- Акцентный цвет + acc_row = tk.Frame(body, bg=BG) + acc_row.pack(fill="x", pady=(8, 0)) + tk.Label(acc_row, text=T("Акцентный цвет:"), bg=BG, fg=DIM, width=14, + anchor="w", font=("Segoe UI", 9)).pack(side="left") + var_accent = tk.StringVar(value=self.settings.get("accent", GREEN)) + for c in ("#6fae4a", "#3f9bd6", "#d6a53f", "#c95b5b", "#9b6fd6"): + sw = tk.Canvas(acc_row, width=18, height=18, bg=BG, + highlightthickness=1, highlightbackground=BAR_BD, + cursor="hand2") + sw.create_rectangle(1, 1, 17, 17, fill=c, outline="") + + def pick(col=c, canvas=sw): + var_accent.set(col) + set_accent(col) + self.btn_play._draw() + self.progress._draw() + for w2 in canvas.master.winfo_children(): + if isinstance(w2, tk.Canvas): + w2.configure(highlightbackground=BAR_BD) + canvas.configure(highlightbackground=c) + sw.bind("", lambda e, f=pick: f()) + sw.pack(side="left", padx=(0, 6)) + if c == var_accent.get(): + sw.configure(highlightbackground=c) + + # --- Автозагрузка и трей + run_row = tk.Frame(body, bg=BG) + run_row.pack(fill="x", pady=(10, 0)) + tk.Label(run_row, text=T("Запуск:"), bg=BG, fg=DIM, width=14, + anchor="w", font=("Segoe UI", 9)).pack(side="left") + var_auto = tk.BooleanVar(value=bool(self.settings.get("autostart"))) + tk.Checkbutton(run_row, text=T("С автозагрузкой Windows"), + variable=var_auto, bg=BG, fg=TEXT, selectcolor=INPUT, + activebackground=BG, activeforeground=TEXT, + highlightthickness=0, + font=("Segoe UI", 9)).pack(side="left") + var_tray_on = tk.BooleanVar(value=bool( + self.settings.get("minimize_to_tray_on_launch"))) + tk.Checkbutton(run_row, text=T("Прятать в трей при запуске игры"), + variable=var_tray_on, bg=BG, fg=TEXT, selectcolor=INPUT, + activebackground=BG, activeforeground=TEXT, + highlightthickness=0, + font=("Segoe UI", 9)).pack(side="left", padx=(10, 0)) + + # --- Прокси (HTTP и SOCKS5 — своя реализация в http.py) + proxy = self.settings.get("proxy") or {} + tk.Label(body, text=T("Прокси:"), bg=BG, fg=DIM, anchor="w", + font=("Segoe UI", 9)).pack(anchor="w", pady=(12, 2)) + px1 = tk.Frame(body, bg=BG) + px1.pack(fill="x") + var_px_on = tk.BooleanVar(value=bool(proxy.get("enabled"))) + tk.Checkbutton(px1, text=T("Использовать прокси"), + variable=var_px_on, bg=BG, fg=TEXT, selectcolor=INPUT, + activebackground=BG, activeforeground=TEXT, + highlightthickness=0, + font=("Segoe UI", 9)).pack(side="left") + tk.Label(px1, text=T("Тип:"), bg=BG, fg=DIM, + font=("Segoe UI", 9)).pack(side="left", padx=(12, 3)) + var_px_type = tk.StringVar(value=proxy.get("type", "http")) + ttk.Combobox(px1, textvariable=var_px_type, width=7, state="readonly", + values=("http", "socks5")).pack(side="left") + tk.Label(px1, text=T("Хост:"), bg=BG, fg=DIM, + font=("Segoe UI", 9)).pack(side="left", padx=(12, 3)) + var_px_host = tk.StringVar(value=proxy.get("host", "")) + tk.Entry(px1, textvariable=var_px_host, width=16, font=("Segoe UI", 9), + bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid", + bd=1, highlightthickness=0).pack(side="left") + tk.Label(px1, text=T("Порт:"), bg=BG, fg=DIM, + font=("Segoe UI", 9)).pack(side="left", padx=(8, 3)) + var_px_port = tk.StringVar(value=str(proxy.get("port", 1080))) + tk.Entry(px1, textvariable=var_px_port, width=6, font=("Segoe UI", 9), + bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid", + bd=1, highlightthickness=0).pack(side="left") + px2 = tk.Frame(body, bg=BG) + px2.pack(fill="x", pady=(3, 0)) + tk.Label(px2, text=T("Логин:"), bg=BG, fg=DIM, width=14, + anchor="w", font=("Segoe UI", 9)).pack(side="left") + var_px_user = tk.StringVar(value=proxy.get("user", "")) + tk.Entry(px2, textvariable=var_px_user, width=14, + font=("Segoe UI", 9), bg=INPUT, fg=TEXT, insertbackground=TEXT, + relief="solid", bd=1, highlightthickness=0).pack(side="left", + padx=(0, 8)) + tk.Label(px2, text=T("Пароль:"), bg=BG, fg=DIM, + font=("Segoe UI", 9)).pack(side="left") + var_px_pass = tk.StringVar(value=proxy.get("pass", "")) + tk.Entry(px2, textvariable=var_px_pass, width=14, show="•", + font=("Segoe UI", 9), bg=INPUT, fg=TEXT, insertbackground=TEXT, + relief="solid", bd=1, highlightthickness=0).pack(side="left", + padx=(3, 6)) + self._btn(px2, T("Проверить прокси"), + lambda: self._test_proxy(d, var_px_on, var_px_type, + var_px_host, var_px_port, + var_px_user, + var_px_pass)).pack(side="left") + self._hint(body, T("HTTP-прокси и SOCKS5. Пустой хост = выключен.")) + + # --- Масштаб интерфейса + sc_row = tk.Frame(body, bg=BG) + sc_row.pack(fill="x", pady=(10, 0)) + tk.Label(sc_row, text=T("Масштаб интерфейса:"), bg=BG, fg=DIM, + width=14, anchor="w", font=("Segoe UI", 9)).pack(side="left") + var_scale = tk.StringVar( + value=f"{int(float(self.settings.get('ui_scale', 1.0)) * 100)}%") + ttk.Combobox(sc_row, textvariable=var_scale, width=7, state="readonly", + values=("100%", "125%", "150%")).pack(side="left") + tk.Label(sc_row, text=T("Применится сразу (шрифты)."), bg=BG, fg=FAINT, + font=("Segoe UI", 8)).pack(side="left", padx=(8, 0)) + + # --- Авто-бэкап + var_backup = tk.BooleanVar(value=bool(self.settings.get("auto_backup"))) + tk.Checkbutton(body, text=T("Авто-бэкап мира перед запуском"), + variable=var_backup, bg=BG, fg=TEXT, selectcolor=INPUT, + activebackground=BG, activeforeground=TEXT, + highlightthickness=0, font=("Segoe UI", 9)).pack( + anchor="w", pady=(10, 0)) + + # --- Зеркала скачивания + tk.Label(body, text=T("Зеркала ресурсов (по одному в строке, пусто = " + "официальные):"), bg=BG, fg=DIM, anchor="w", + font=("Segoe UI", 9)).pack(anchor="w", pady=(12, 2)) + txt_assets = tk.Text(body, height=2, font=("Consolas", 9), bg=INPUT, + fg=TEXT, insertbackground=TEXT, relief="solid", bd=1, + highlightthickness=0) + txt_assets.pack(fill="x") + txt_assets.insert("1.0", "\n".join( + self.settings.get("asset_mirrors") or [])) + + tk.Label(body, text=T("Зеркала библиотек (по одному в строке, пусто = " + "официальные):"), bg=BG, fg=DIM, anchor="w", + font=("Segoe UI", 9)).pack(anchor="w", pady=(8, 2)) + txt_libs = tk.Text(body, height=2, font=("Consolas", 9), bg=INPUT, + fg=TEXT, insertbackground=TEXT, relief="solid", bd=1, + highlightthickness=0) + txt_libs.pack(fill="x") + txt_libs.insert("1.0", "\n".join( + self.settings.get("lib_mirrors") or [])) + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(12, 0)) + + def lines(widget): + return [x.strip() for x in widget.get("1.0", "end").splitlines() + if x.strip()] + + def save(): + path = var_dir.get().strip() + if path: + self.settings["game_dir"] = path + os.makedirs(path, exist_ok=True) + stats_mod.set_path(os.path.join(path, ".launcher")) + self.settings["lang"] = var_lang.get() + self.settings["accent"] = var_accent.get() + self.settings["theme"] = var_theme.get() + self.settings["asset_mirrors"] = lines(txt_assets) + self.settings["lib_mirrors"] = lines(txt_libs) + try: + px_port = int(var_px_port.get().strip() or 1080) + except ValueError: + px_port = 1080 + self.settings["proxy"] = { + "enabled": bool(var_px_on.get()), + "type": var_px_type.get() if var_px_type.get() in + ("http", "socks5") else "http", + "host": var_px_host.get().strip(), + "port": px_port, + "user": var_px_user.get().strip(), + "pass": var_px_pass.get(), + } + try: + self.settings["ui_scale"] = (float(var_scale.get() + .rstrip("%")) / 100) + except ValueError: + self.settings["ui_scale"] = 1.0 + self.settings["auto_backup"] = bool(var_backup.get()) + new_auto = bool(var_auto.get()) + if new_auto != bool(self.settings.get("autostart")): + self._set_autostart(new_auto) + self.settings["autostart"] = new_auto + self.settings["minimize_to_tray_on_launch"] = bool( + var_tray_on.get()) + config.save_settings(self.settings) + set_language(self.settings["lang"]) + set_theme(self.settings["theme"]) + set_accent(self.settings["accent"]) + http.configure(self.settings.get("proxy")) + self._apply_ui_scale() + self._rebuild_ui() + d.destroy() + self._log(T("Настройки сохранены"), "ok") + + self._btn(btns, T("Сохранить"), save, primary=True).pack(side="right") + self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6)) + self._finish_dialog(d) + + def _rebuild_ui(self): + """Пересобирает главное окно (после смены языка/настроек).""" + for c in self.winfo_children(): + if isinstance(c, tk.Toplevel): + continue + try: + c.destroy() + except tk.TclError: + pass + self._log_txt_widget = None + self.configure(bg=BG) + self._build() + self._load_settings_to_ui() + self._update_pill() + self._fill_news(self._news) + if self.busy: + self.btn_play.set_state("disabled") + self.btn_cancel.pack() + nick = self.var_nick.get().strip() + if re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick): + threading.Thread(target=self._load_head_bg, args=(nick,), + daemon=True).start() + + def _open_log(self): + d = self._dialog(T("Журнал"), width=620) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=12, pady=10) + + # --- источник: живой сеанс или архивный файл игры + logs_dir = os.path.join(self._game_dir(), ".launcher", "logs") + try: + files = sorted((f for f in os.listdir(logs_dir) + if f.startswith("game-") and f.endswith(".log")), + reverse=True)[:40] + except OSError: + files = [] + top = tk.Frame(body, bg=BG) + top.pack(fill="x") + tk.Label(top, text=T("Архив логов:"), bg=BG, fg=DIM, + font=("Segoe UI", 9)).pack(side="left") + var_src = tk.StringVar(value=T("Сеанс (живой)")) + ttk.Combobox(top, textvariable=var_src, width=38, state="readonly", + values=[T("Сеанс (живой)")] + files).pack(side="left", + padx=(6, 0)) + + txt = tk.Text(body, bg=INPUT, fg=DIM, font=("Consolas", 9), + relief="solid", bd=1, highlightthickness=0, state="disabled", + wrap="word", width=70, height=15) + txt.pack(fill="both", expand=True, pady=(8, 0)) + txt.tag_configure("ok", foreground=OK) + txt.tag_configure("err", foreground=ERR) + txt.tag_configure("warn", foreground=WARN) + txt.tag_configure("ts", foreground=FAINT) + if THEME == "light": + txt.tag_configure("game", foreground=GREEN_LO) + txt.tag_configure("hit", background=mix(GREEN, "#ffffff", 0.75), + foreground=TEXT) + else: + txt.tag_configure("game", foreground="#a8c48a") + txt.tag_configure("hit", background="#4a5a3a", + foreground="#ffffff") + txt.tag_lower("hit") + self._log_txt_widget = txt + self._log_view_mode = "session" + + # --- фильтр «только ошибки» + поиск Ctrl+F + var_err = tk.BooleanVar(value=False) + err_keys = ("error", "exception", "fatal", "caused by", + "outofmemory", "crash", "ошибка", "фатальн") + + def render_file(): + if self._log_view_mode == "session": + return + lines_ = self._log_orig_lines + if var_err.get(): + lines_ = [ln for ln in lines_ + if any(k in ln.lower() for k in err_keys)] + txt.configure(state="normal") + txt.delete("1.0", "end") + for ln in lines_[:6000]: + txt.insert("end", ln + "\n", "game") + txt.configure(state="disabled") + + def load_src(*_a): + src = var_src.get() + self._find_state.update(idx=-1, n=0) + txt.tag_remove("hit", "1.0", "end") + if src == T("Сеанс (живой)"): + self._log_view_mode = "session" + self._log_orig_lines = [] + err_chk.configure(state="disabled") + txt.configure(state="normal") + txt.delete("1.0", "end") + for ts, msg, tag in self._log_lines: + txt.insert("end", ts + " ", "ts") + txt.insert("end", msg + "\n", tag or ()) + txt.configure(state="disabled") + lbl_cnt.configure(text="") + else: + self._log_view_mode = src + err_chk.configure(state="normal") + try: + with open(os.path.join(logs_dir, src), encoding="utf-8", + errors="replace") as f: + self._log_orig_lines = f.read().splitlines() + except OSError as e: + self._log_orig_lines = ["<" + str(e) + ">"] + render_file() + lbl_cnt.configure(text=f"{len(self._log_orig_lines)} " + + T("строка")) + var_src.trace_add("write", load_src) + + find_row = tk.Frame(body, bg=BG) + find_row.pack(fill="x", pady=(8, 0)) + tk.Label(find_row, text=T("Найти:"), bg=BG, fg=DIM, + font=("Segoe UI", 9)).pack(side="left") + var_find = tk.StringVar() + e_find = tk.Entry(find_row, textvariable=var_find, width=26, + font=("Segoe UI", 9), bg=INPUT, fg=TEXT, + insertbackground=TEXT, relief="solid", bd=1, + highlightthickness=0) + e_find.pack(side="left", padx=(4, 6)) + lbl_cnt = tk.Label(find_row, text="", bg=BG, fg=DIM, + font=("Segoe UI", 8)) + err_chk = tk.Checkbutton(find_row, text=T("только ошибки"), + variable=var_err, command=render_file, + bg=BG, fg=DIM, selectcolor=INPUT, + activebackground=BG, activeforeground=TEXT, + highlightthickness=0, state="disabled", + font=("Segoe UI", 8)) + err_chk.pack(side="right") + + def goto(delta): + q = var_find.get() + matches = [] + if q: + pos = "1.0" + while True: + pos = txt.search(q, pos, stopindex="end", nocase=True) + if not pos: + break + end = "%s+%dc" % (pos, len(q)) + matches.append((pos, end)) + pos = end + txt.tag_remove("hit", "1.0", "end") + if not matches: + self._find_state.update(idx=-1, n=0) + lbl_cnt.configure(text=T("Нет совпадений"), fg=ERR) + return + idx = (self._find_state.get("idx", -1) + delta) % len(matches) + self._find_state.update(idx=idx, n=len(matches)) + a, b = matches[idx] + txt.tag_add("hit", a, b) + txt.see(a) + lbl_cnt.configure(text=T("Найдено: ") + f"{idx + 1}/{len(matches)}", + fg=DIM) + + e_find.bind("", lambda e: goto(1)) + e_find.bind("", lambda e: goto(-1)) + txt.bind("", lambda e: (e_find.focus_set(), "break")[1]) + self._btn(find_row, "▶", lambda: goto(1)).pack(side="left", padx=2) + self._btn(find_row, "◀", lambda: goto(-1)).pack(side="left") + load_src() # показать текущий сеанс сразу при открытии + + def on_close(): + d.destroy() + try: + if self._log_txt_widget is txt: + self._log_txt_widget = None + self._log_view_mode = "session" + except tk.TclError: + self._log_txt_widget = None + self._log_view_mode = "session" + d.protocol("WM_DELETE_WINDOW", on_close) + + def copy_all(): + try: + self.clipboard_clear() + self.clipboard_append(txt.get("1.0", "end-1c")) + except tk.TclError: + pass + txt.bind("", lambda e: (copy_all(), "break")[1]) + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(10, 0)) + self._btn(btns, T("Копировать"), copy_all).pack(side="left") + self._btn(btns, T("Обновить"), load_src).pack(side="left", padx=(6, 0)) + self._btn(btns, T("Закрыть"), on_close, primary=True).pack(side="right") + self._finish_dialog(d) + + def _journal_open(self): + txt = self._log_txt_widget + return txt is not None and txt.winfo_exists() + + # ------------------------------------------------------------ новшества + def _apply_ui_scale(self): + """Масштаб шрифтов (tk scaling) из настроек — сразу, без перезапуска.""" + try: + sc = float(self.settings.get("ui_scale", 1.0)) + except (TypeError, ValueError): + sc = 1.0 + self.tk.call("tk", "scaling", self._base_scaling * sc) + + def _test_proxy(self, d, var_on, var_type, var_host, var_port, + var_user, var_pass): + """Пробный запрос через прокси из формы (результат — в журнал).""" + try: + port = int(var_port.get().strip() or 1080) + except ValueError: + port = 1080 + cfg = {"enabled": True, + "type": var_type.get() if var_type.get() in ("http", "socks5") + else "http", + "host": var_host.get().strip(), "port": port, + "user": var_user.get(), + "pass": var_pass.get()} + + def bg(): + http.configure(cfg) + try: + http.get_bytes("https://api.modrinth.com/v2/search?limit=1", + timeout=12) + ok, err = True, "" + except Exception as e: # noqa: BLE001 + ok, err = False, str(e) + finally: + http.configure(self.settings.get("proxy")) + self.ui_q.put(("log", T("Прокси работает") if ok + else T("Прокси не работает: ") + err, + "ok" if ok else "err")) + threading.Thread(target=bg, daemon=True).start() + + def _version_context(self): + """(mc-версия, лоадер) выбранной версии: fabric-*/quilt-* — локальные.""" + vid = self._selected_version_id() + m = re.match(r"(fabric|quilt)-loader-.+-(\d[\w.\-]*)$", vid) + if m: + return m.group(2), m.group(1) + return vid, "" + + def _startup_mod_updates(self): + """Тихая фоновая проверка обновлений модов при старте.""" + def bg(): + try: + mc, loader = self._version_context() + mc_f = mc if re.match(r"\d", mc) else "" + q_loader = loader if loader in ("fabric", "quilt") else "" + if not q_loader or not mods_mod.list_mods(self._game_dir()): + return + ups = mods_mod.check_updates(self._game_dir(), mc_f, q_loader) + if ups and not self.busy: + self.ui_q.put(("log", + T("Есть обновления модов: {n}. " + "Откройте «Моды» для обновления.") + .format(n=len(ups)), "warn")) + except Exception: # noqa: BLE001 + pass + threading.Thread(target=bg, daemon=True).start() + + def _open_mods(self): + mc, loader = self._version_context() + d = self._dialog(T("Моды"), width=600) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + lq = queue.Queue() + state = {"hits": []} + mc_f = mc if re.match(r"\d", mc) else "" + q_loader = loader if loader in ("fabric", "quilt") else "" + + def poll(): + if not d.winfo_exists(): + return + try: + while True: + msg = lq.get_nowait() + if msg[0] == "search": + state["hits"] = msg[1] + res_lb.delete(0, "end") + for h in state["hits"]: + res_lb.insert( + "end", f"{h['title']} · " + f"{h['downloads']:,}".replace(",", " ") + + " " + T("загрузок")) + if state["hits"]: + res_lb.selection_set(0) + show_desc() + lbl_desc.configure( + text=T("Ничего не найдено") + if not state["hits"] else "") + elif msg[0] == "updates": + show_updates(msg[1]) + elif msg[0] == "mods": + refresh_local() + elif msg[0] == "log": + self._log(msg[1], msg[2] if len(msg) > 2 else None) + except queue.Empty: + pass + d.after(80, poll) + d.after(80, poll) + + # --- поиск + head = tk.Frame(body, bg=BG) + head.pack(fill="x") + tk.Label(head, text=T("Поиск модов на Modrinth:") + " " + + T("Моды для {v}").format(v=mc) + + (" · " + q_loader if q_loader else ""), bg=BG, fg=DIM, + font=("Segoe UI", 9)).pack(anchor="w", pady=(0, 4)) + row = tk.Frame(body, bg=BG) + row.pack(fill="x") + var_q = tk.StringVar() + e = tk.Entry(row, textvariable=var_q, font=("Segoe UI", 9), + bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid", + bd=1, highlightthickness=1, highlightbackground=INPUT_BD) + e.pack(side="left", fill="x", expand=True, ipady=2) + e.focus_set() + + def do_search(*_a): + q = var_q.get().strip() # читаем в главном потоке (Tk не потокобезопасен) + + def bg(): + try: + hits, _total = modrinth_mod.search(q, mc_f, q_loader) + except Exception as e_: # noqa: BLE001 + hits = [] + lq.put(("log", T("Ошибка: ") + str(e_), "err")) + lq.put(("search", hits)) + lq.put(("log", T("Идёт поиск…"))) + threading.Thread(target=bg, daemon=True).start() + self._btn(row, T("Искать"), do_search).pack(side="left", padx=(6, 0)) + e.bind("", lambda x: do_search()) + + res_lb = tk.Listbox(body, height=7, bg=INPUT, fg=TEXT, + selectbackground=GREEN, selectforeground="#0d1a0d", + relief="solid", bd=1, highlightthickness=0, + font=("Segoe UI", 9), exportselection=False) + res_lb.pack(fill="x", pady=(8, 0)) + lbl_desc = tk.Label(body, text="", bg=BG, fg=DIM, anchor="w", + justify="left", wraplength=560, height=3, + font=("Segoe UI", 8)) + + def show_desc(*_a): + sel = res_lb.curselection() + if sel and sel[0] < len(state["hits"]): + lbl_desc.configure(text=state["hits"][sel[0]]["desc"][:280]) + res_lb.bind("<>", show_desc) + lbl_desc.pack(fill="x", pady=(2, 0)) + + def install_mod(): + sel = res_lb.curselection() + if not sel or sel[0] >= len(state["hits"]): + return + h = state["hits"][sel[0]] + + def bg(): + try: + vs = modrinth_mod.versions(h["id"], mc_f, q_loader) + if not vs: + lq.put(("log", T("Ничего не найдено"), "warn")) + return + v = vs[0] + dest = os.path.join(mods_mod.mods_dir(self._game_dir()), + v["filename"]) + lq.put(("log", T("Установка: ") + v["filename"])) + ok = modrinth_mod.download_mod(v["url"], dest, + size=v["size"]) + lq.put(("log", (T("Мод установлен: ") + v["filename"]) + if ok else T("Ошибка: ") + v["filename"], + "ok" if ok else "err")) + except Exception as e_: # noqa: BLE001 + lq.put(("log", T("Ошибка: ") + str(e_), "err")) + finally: + lq.put(("mods",)) + threading.Thread(target=bg, daemon=True).start() + self._btn(body, T("Установить"), install_mod).pack(anchor="w", + pady=(4, 0)) + + # --- установленные моды + tk.Label(body, text=T("Установленные моды"), bg=BG, fg=TEXT, + font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(10, 2)) + loc_lb = tk.Listbox(body, height=5, bg=INPUT, fg=TEXT, + selectbackground=GREEN, selectforeground="#0d1a0d", + relief="solid", bd=1, highlightthickness=0, + font=("Consolas", 9), exportselection=False) + loc_lb.pack(fill="x") + + def refresh_local(): + loc_lb.delete(0, "end") + state["local"] = mods_mod.list_mods(self._game_dir()) + for m in state["local"]: + loc_lb.insert("end", ("● " if m["enabled"] else "○ ") + + m["name"] + " (" + mods_mod.fmt_size( + m["size"]) + ")") + refresh_local() + + def selected_mod(): + sel = loc_lb.curselection() + if not sel or sel[0] >= len(state.get("local") or []): + return None + return state["local"][sel[0]] + + def toggle_mod(): + m = selected_mod() + if not m: + return + new = mods_mod.set_enabled(self._game_dir(), m["file"], + not m["enabled"]) + if new: + self._log((T("Мод включён: ") if not m["enabled"] + else T("Мод отключён: ")) + m["name"], "ok") + refresh_local() + + def delete_mod(): + m = selected_mod() + if not m: + return + if not messagebox.askyesno( + "PixelLauncher", + T("Удалить") + " " + m["name"] + "?", parent=d): + return + if mods_mod.remove_mod(self._game_dir(), m["file"]): + self._log(T("Мод удалён: ") + m["name"], "ok") + refresh_local() + + def api_mod(): + if not q_loader: + self._log(T("Нет Fabric/Quilt-версии — API не ставится."), + "warn") + return + + def bg(): + n = modrinth_mod.ensure_loader_api( + self._game_dir(), mc_f, q_loader, + log=lambda m_: lq.put(("log", m_))) + if not n: + lq.put(("log", T("Уже установлено"), "ok")) + lq.put(("mods",)) + threading.Thread(target=bg, daemon=True).start() + + # --- проверка обновлений установленных модов + state["updates"] = [] + + def check_updates(): + lq.put(("log", T("Проверка обновлений…"))) + + def bg(): + try: + ups = mods_mod.check_updates( + self._game_dir(), mc_f, q_loader, + log=lambda m_: lq.put(("log", m_))) + except Exception as err: # noqa: BLE001 + ups = [] + lq.put(("log", T("Ошибка: ") + str(err), "err")) + lq.put(("updates", ups)) + threading.Thread(target=bg, daemon=True).start() + + def show_updates(ups): + state["updates"] = ups + if not ups: + self._log(T("Обновлений не найдено"), "ok") + return + self._log(T("Обновлений: ") + str(len(ups)), "warn") + for u in ups: + self._log("→ " + u["name"] + ": " + + (u["cur"] or "?") + " → " + u["new_ver"], "warn") + ud = self._dialog(T("Обновления модов"), width=520) + ubody = tk.Frame(ud, bg=BG) + ubody.pack(fill="both", expand=True, padx=14, pady=10) + ulb = tk.Listbox(ubody, height=min(10, len(ups)), bg=INPUT, fg=TEXT, + selectbackground=GREEN, selectforeground="#0d1a0d", + relief="solid", bd=1, highlightthickness=0, + font=("Consolas", 9), exportselection=False) + ulb.pack(fill="both", expand=True) + for u in ups: + ulb.insert("end", f"{u['name']:<28} " + f"{u['cur'] or '?'} → {u['new_ver']}") + if ups: + ulb.selection_set(0) + + def apply_updates(indices): + picked = [ups[i] for i in indices + if 0 <= i < len(ups)] + if not picked: + return + ud.destroy() + + def bg(): + ok_n = 0 + for u in picked: + lq.put(("log", T("Обновление: ") + u["new_file"])) + if mods_mod.apply_update(self._game_dir(), u): + ok_n += 1 + lq.put(("log", T("Мод обновлён: ") + u["name"], + "ok")) + else: + lq.put(("log", T("Не удалось обновить: ") + + u["new_file"], "err")) + lq.put(("log", T("Обновлено: ") + str(ok_n) + "/" + + str(len(picked)), "ok" if ok_n == len(picked) + else "warn")) + lq.put(("mods",)) + threading.Thread(target=bg, daemon=True).start() + + ubtns = tk.Frame(ubody, bg=BG) + ubtns.pack(fill="x", pady=(10, 0)) + self._btn(ubtns, T("Закрыть"), ud.destroy).pack(side="right") + self._btn(ubtns, T("Обновить всё"), + lambda: apply_updates(range(len(ups)))).pack( + side="right", padx=(0, 6)) + + def update_sel(): + sel = ulb.curselection() + if sel: + apply_updates(list(sel)) + self._btn(ubtns, T("Обновить выбранное"), update_sel).pack( + side="left") + self._finish_dialog(ud) + + brow = tk.Frame(body, bg=BG) + brow.pack(fill="x", pady=(6, 0)) + self._btn(brow, T("Включить") + "/" + T("Выключить"), + toggle_mod).pack(side="left") + self._btn(brow, T("Удалить"), delete_mod).pack(side="left", + padx=(6, 0)) + self._btn(brow, T("Установить Fabric API"), + api_mod).pack(side="left", padx=(6, 0)) + self._btn(brow, T("Проверить обновления"), + check_updates).pack(side="left", padx=(6, 0)) + self._btn(brow, T("Каталог модпаков…"), + self._open_packs).pack(side="left", padx=(6, 0)) + self._btn(brow, T("Папка mods"), lambda: self._open_explorer( + mods_mod.mods_dir(self._game_dir()))).pack(side="right") + self._finish_dialog(d) + return d + + def _import_pack(self, path=None): + """Импорт модпака .mrpack: лоадер + файлы + overrides.""" + if self.busy: + return + if path is None: + path = filedialog.askopenfilename( + parent=self, title=T("Модпаки"), + filetypes=[("mrpack", "*.mrpack"), (T("Все файлы"), "*.*")]) + if not path: + return + self.busy = True + self._cancel = threading.Event() + self.btn_play.set_state("disabled") + self.btn_cancel.configure(state="normal") + self.btn_cancel.pack() + self.progress.set(0) + + def bg(): + log = lambda m, t=None: self.ui_q.put(("log", m, t)) # noqa: E731 + try: + log(T("Установка модпака: ") + os.path.basename(path)) + vid, got = mrpack_mod.install( + self._game_dir(), path, log=log, + progress=lambda f: self.ui_q.put( + ("progress", f * 100, 100, T("Модпак"))), + cancel=self._cancel) + log(T("Модпак установлен: ") + os.path.basename(path) + + f" ({got})", "ok") + if vid: + self.ui_q.put(("use_version", vid)) + self.ui_q.put(("reload_versions",)) + except http.Cancelled: + log(T("Загрузка отменена"), "warn") + except Exception as e: # noqa: BLE001 + log(T("Ошибка: ") + str(e), "err") + finally: + self.ui_q.put(("done",)) + threading.Thread(target=bg, daemon=True).start() + + def _open_history(self): + d = self._dialog(T("История и статистика"), width=560) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + + def fill(): + t = stats_mod.totals() + lbl_top.configure( + text=T("Всего времени: ") + + stats_mod.fmt_duration(t["total_seconds"]) + + " · " + T("Запусков: ") + str(t["total_launches"])) + lb1.delete(0, "end") + for r in t["per_version"]: + lb1.insert("end", f"{r['ver']:<26} " + + str(r["count"]) + " " + T("шт.") + " · " + + stats_mod.fmt_duration(r["seconds"]) + " · " + + stats_mod.fmt_time(r["last"]) + + (f" · {r['crashes']} " + T("вылетов") + if r["crashes"] else "")) + lb2.delete(0, "end") + for r in t["recent"]: + ok_txt = T("Ок") if r.get("ok") else f"{T('вылет')} ({r.get('code')})" + lb2.insert("end", stats_mod.fmt_time(r.get("ts")) + " · " + + str(r.get("ver")) + " · " + ok_txt + " · " + + stats_mod.fmt_duration(r.get("seconds"))) + + lbl_top = tk.Label(body, text="", bg=BG, fg=TEXT, anchor="w", + font=("Segoe UI", 10, "bold")) + lbl_top.pack(fill="x") + lb1 = tk.Listbox(body, height=7, bg=INPUT, fg=TEXT, relief="solid", + bd=1, highlightthickness=0, font=("Consolas", 9), + exportselection=False) + lb1.pack(fill="x", pady=(8, 0)) + tk.Label(body, text=T("Последние запуски"), bg=BG, fg=DIM, + font=("Segoe UI", 9)).pack(anchor="w", pady=(10, 2)) + lb2 = tk.Listbox(body, height=6, bg=INPUT, fg=DIM, relief="solid", + bd=1, highlightthickness=0, font=("Consolas", 9), + exportselection=False) + lb2.pack(fill="x") + fill() + + def clear_hist(): + if not messagebox.askyesno("PixelLauncher", + T("Очистить историю") + "?", + parent=d): + return + stats_mod.clear() + fill() + self._log(T("История очищена"), "ok") + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(12, 0)) + self._btn(btns, T("Очистить историю"), clear_hist).pack(side="left") + self._btn(btns, T("Закрыть"), d.destroy, primary=True).pack( + side="right") + self._finish_dialog(d) + + def _open_skin(self): + d = self._dialog(T("Скин-превью"), width=300) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + var_nick = tk.StringVar(value=self.var_nick.get()) + row = tk.Frame(body, bg=BG) + row.pack(fill="x") + tk.Label(row, text=T("Ник:"), bg=BG, fg=DIM, + font=("Segoe UI", 9)).pack(side="left") + e = tk.Entry(row, textvariable=var_nick, width=16, + font=("Segoe UI", 9), bg=INPUT, fg=TEXT, + insertbackground=TEXT, relief="solid", bd=1, + highlightthickness=0) + e.pack(side="left", padx=(4, 6), ipady=2) + e.focus_set() + Z = 8 + cv = tk.Canvas(body, width=16 * Z + 8, height=32 * Z + 8, bg="#0c0c0c", + highlightthickness=1, highlightbackground=INPUT_BD) + cv.pack(pady=(10, 0)) + refs = [] + + def render(path): + if not cv.winfo_exists(): + return + try: + img = tk.PhotoImage(file=path) + except tk.TclError: + self._log(T("Скин не найден"), "err") + return + refs.clear() + refs.append(img) # PhotoImage живёт, пока ссылка в списке + cv.delete("all") + dec = _png_rgba(path) # альфа из PNG (tk photo get её не отдаёт) + cells = {} # клетка -> (r, g, b) с учётом наложений + for sx, sy, ex, ey, dx, dy in _skin_parts(img.height() >= 64): + for j in range(ey - sy): + for i in range(ex - sx): + if dec: + off = ((sy + j) * dec[0] + sx + i) * 4 + rgba = dec[2][off:off + 4] + rgb = (rgba[0], rgba[1], rgba[2]) + alpha = rgba[3] + else: + try: + px = img.get(sx + i, sy + j) + except tk.TclError: + return + rgb = tuple(px[:3]) + # без альфы: чистый чёрный — вероятная прозрачность + alpha = 0 if rgb == (0, 0, 0) else 255 + if not alpha: + continue + key = (dx + i, dy + j) + if alpha >= 255: + cells[key] = rgb + else: + base = cells.get(key) + if base is None: + cells[key] = rgb + else: # полупрозрачный оверлей поверх базы + cells[key] = tuple( + (rgb[k] * alpha + base[k] * (255 - alpha)) + // 255 for k in range(3)) + for (gx, gy), rgb in cells.items(): + x0 = 4 + gx * Z + y0 = 4 + gy * Z + cv.create_rectangle(x0, y0, x0 + Z, y0 + Z, + fill="#%02x%02x%02x" % rgb, outline="") + self._log(T("Скин загружен"), "ok") + + def load_nick(): + nick = var_nick.get().strip() + if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick): + self._log(T("Ник: 3-16 символов, латиница, цифры и _"), "warn") + return + cache = os.path.join(self._game_dir(), ".launcher", "heads", + nick + ".skin.png") + try: + fresh = (os.path.isfile(cache) and time.time() + - os.path.getmtime(cache) < 86400) + if not fresh: + data = http.get_bytes( + f"https://mc-heads.net/skin/{nick}", timeout=10) + os.makedirs(os.path.dirname(cache), exist_ok=True) + with open(cache, "wb") as f: + f.write(data) + self.ui_q.put(("skin_path", cache)) + except Exception as e: # noqa: BLE001 + self.ui_q.put(("log", T("Скин не найден") + ": " + str(e), + "err")) + + def load_file(): + path = filedialog.askopenfilename( + parent=d, title=T("Своё фото…"), + filetypes=[("PNG", "*.png"), (T("Все файлы"), "*.*")]) + if path: + render(path) + + brow = tk.Frame(body, bg=BG) + brow.pack(fill="x", pady=(10, 0)) + self._btn(brow, T("Обновить"), + lambda: threading.Thread(target=load_nick, + daemon=True).start()).pack( + side="left") + self._btn(brow, T("Своё фото…"), load_file).pack(side="left", + padx=(6, 0)) + self._skin_render = render + + def close(): + if getattr(self, "_skin_render", None) is render: + self._skin_render = None + d.destroy() + d.protocol("WM_DELETE_WINDOW", close) + self._finish_dialog(d) + threading.Thread(target=load_nick, daemon=True).start() + + def _make_shortcut(self, profile=None): + """Ярлык на рабочем столе: pythonw main.py [--version|--profile]. + + profile — dict профиля: ярлык создаётся сразу на рабочем столе + без диалога и открывает лаунчер с применением этого профиля. + """ + name = "" + if profile is not None: + name = str(profile.get("name") or "").strip() + safe = re.sub(r'[<>:"/\\|?*]', "", name)[:60].strip() or "profile" + desk = os.path.join(os.path.expanduser("~"), "Desktop") + if not os.path.isdir(desk): + desk = os.path.join(os.path.expanduser("~"), "OneDrive", + "Desktop") + path = os.path.join(desk, f"PixelLauncher {safe}.lnk") + else: + vid = self.var_ver.get().strip() or self._selected_version_id() + safe = re.sub(r"[^\w.\-]+", "_", vid)[:50] or "version" + path = filedialog.asksaveasfilename( + parent=self, title=T("Создать ярлык версии…"), + defaultextension=".lnk", + initialfile=f"PixelLauncher {safe}.lnk", + filetypes=[("Ярлык", "*.lnk")]) + if not path: + return + exe = sys.executable + if exe.lower().endswith("python.exe"): + cand = exe[:-len("python.exe")] + "pythonw.exe" + if os.path.isfile(cand): + exe = cand + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + main_py = os.path.join(root, "main.py") + + def ps(s): # строка для PowerShell (одинарные кавычки) + return "'" + str(s).replace("'", "''") + "'" + if profile is not None: + qname = name.replace('"', "'") + args = f'"{main_py}" --profile "{qname}"' + else: + args = f'"{main_py}" --version {vid}' + ico = next((c for c in (os.path.join(root, "launcher.ico"), + os.path.join(root, "build", "launcher.ico")) + if os.path.isfile(c)), "") + script = ("$sh = New-Object -ComObject WScript.Shell; " + f"$l = $sh.CreateShortcut({ps(path)}); " + f"$l.TargetPath = {ps(exe)}; " + f"$l.Arguments = {ps(args)}; " + f"$l.WorkingDirectory = {ps(root)}; " + + (f"$l.IconLocation = {ps(ico)}; " if ico else "") + + "$l.Save()") + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-STA", "-Command", script], + capture_output=True, timeout=25, + creationflags=CREATE_NO_WINDOW) + if r.returncode != 0: + raise RuntimeError( + r.stderr.decode(errors="replace")[:200] or "rc!=0") + if profile is not None and os.path.isfile(path): + # выделяем ярлык в проводнике — сразу видно результат + subprocess.run(["explorer", "/select,", path], + capture_output=True, timeout=10, + creationflags=CREATE_NO_WINDOW) + self._log(T("Ярлык создан: ") + path, "ok") + except Exception as e: # noqa: BLE001 + self._log(T("Не удалось создать ярлык") + ": " + str(e), "err") + + def _set_autostart(self, on): + """Ярлык в папке автозагрузки Windows (или его удаление).""" + base = os.environ.get("APPDATA") or os.path.expanduser("~") + path = os.path.join(base, "Microsoft", "Windows", "Start Menu", + "Programs", "Startup", "PixelLauncher.lnk") + try: + if not on: + if os.path.isfile(path): + os.remove(path) + self._log(T("Автозагрузка отключена"), "ok") + return True + exe = sys.executable + if exe.lower().endswith("python.exe"): + cand = exe[:-len("python.exe")] + "pythonw.exe" + if os.path.isfile(cand): + exe = cand + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + main_py = os.path.join(root, "main.py") + ico = next((c for c in (os.path.join(root, "launcher.ico"), + os.path.join(root, "build", "launcher.ico")) + if os.path.isfile(c)), "") + + def ps(s): + return "'" + str(s).replace("'", "''") + "'" + script = ("$sh = New-Object -ComObject WScript.Shell; " + f"$l = $sh.CreateShortcut({ps(path)}); " + f"$l.TargetPath = {ps(exe)}; " + f"$l.Arguments = {ps('\"' + main_py + '\"')}; " + f"$l.WorkingDirectory = {ps(root)}; " + + (f"$l.IconLocation = {ps(ico)}; " if ico else "") + + "$l.Save()") + r = subprocess.run( + ["powershell", "-NoProfile", "-STA", "-Command", script], + capture_output=True, timeout=25, creationflags=CREATE_NO_WINDOW) + if r.returncode != 0: + raise RuntimeError( + r.stderr.decode(errors="replace")[:200] or "rc!=0") + self._log(T("Автозагрузка включена: ") + path, "ok") + return True + except Exception as e: # noqa: BLE001 + self._log(T("Не удалось изменить автозагрузку") + ": " + str(e), + "err") + return False + + def _export_settings(self): + path = filedialog.asksaveasfilename( + parent=self, title=T("Экспорт настроек…"), + defaultextension=".json", + initialfile="pixellauncher-settings.json", + filetypes=[("JSON", "*.json")]) + if not path: + return + try: + self._save_settings() + with open(path, "w", encoding="utf-8") as f: + json.dump(self.settings, f, ensure_ascii=False, indent=2) + self._log(T("Настройки экспортированы: ") + path, "ok") + except OSError as e: # noqa: BLE001 + self._log(T("Ошибка: ") + str(e), "err") + + def _import_settings(self): + path = filedialog.askopenfilename( + parent=self, title=T("Импорт настроек…"), + filetypes=[("JSON", "*.json"), (T("Все файлы"), "*.*")]) + if not path: + return + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError("не JSON-объект") + except (OSError, ValueError) as e: # noqa: BLE001 + self._log(T("Не удалось прочитать файл настроек") + ": " + + str(e), "err") + return + merged = dict(config.DEFAULTS) + merged.update(data) + config._migrate(merged) + self.settings.clear() + self.settings.update(merged) + config.save_settings(self.settings) + set_language(self.settings.get("lang", "ru")) + set_theme(self.settings.get("theme", "dark")) + set_accent(self.settings.get("accent", GREEN)) + http.configure(self.settings.get("proxy")) + stats_mod.set_path(os.path.join(self._game_dir(), ".launcher")) + self._scene = self._pick_scene() + self._night = self._scene == "night" + self._apply_ui_scale() + self._rebuild_ui() + self._reload_versions() + nick = self.settings.get("username", "Player") + threading.Thread(target=self._load_head_bg, args=(nick,), + daemon=True).start() + self._log(T("Настройки импортированы"), "ok") + + # ------------------------------------------------------------ трей + def _start_tray(self): + if os.name != "nt" or self._tray is not None: + return + try: + hicon = tray_mod.window_icon(self.winfo_id()) + self._tray = tray_mod.Tray( + on_activate=lambda: self.ui_q.put(("tray", "show")), + on_command=lambda name: self.ui_q.put(("tray", name)), + commands=[(T("Играть"), "play"), (T("Журнал"), "journal")]) + self._tray.start(tooltip="PixelLauncher", hicon=hicon) + except Exception: # noqa: BLE001 — трей не критичен + self._tray = None + + def _tray_event(self, name): + if name == "exit": + self._on_close() + return + try: + self.deiconify() + self.lift() + self.focus_force() + except tk.TclError: + pass + if name == "play": + self._on_play() + elif name == "journal" and not self._journal_open(): + self._open_log() + + def _tray_notify(self, title, text, warn=False): + if self._tray is not None: + try: + self._tray.notify(str(title), str(text), warn=bool(warn)) + except Exception: # noqa: BLE001 + pass + + # ------------------------------------------------------------ обновление + def _check_update(self, quiet=False): + """Проверяет /update.json; quiet — показывать окна только если есть + новая версия.""" + if self._update_active: + return + self._update_active = True + self._update_quiet = bool(quiet) + if not quiet: + self.lbl_status.configure(text=T("Проверка обновления…"), fg=DIM) + threading.Thread(target=self._update_check_bg, args=(quiet,), + daemon=True).start() + + def _update_check_bg(self, quiet): + try: + manifest = selfupdate.check() + error = None + except Exception as e: # noqa: BLE001 + manifest, error = None, str(e) + self.ui_q.put(("update_found", manifest, error, quiet)) + + def _on_update_found(self, manifest, error, quiet): + """Главный поток: диалог о новой версии, запуск скачивания.""" + if error: + self._update_active = False + self._update_quiet = False + if not quiet: + self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM) + messagebox.showerror( + T("Обновление"), + T("Не удалось проверить обновления: ") + error, + parent=self) + return + if not manifest: + self._update_active = False + self._update_quiet = False + if not quiet: + self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM) + messagebox.showinfo(T("Обновление"), + T("У вас последняя версия"), + parent=self) + return + notes = str(manifest.get("notes") or "").strip() + text = T("Новая версия {v} (у вас {c}).\n\n{notes}\n\n" + "Скачать и установить?").format( + v=str(manifest.get("version")), c=LAUNCHER_VERSION, + notes=notes or "—") + if not messagebox.askyesno(T("Обновление"), text, parent=self): + self._update_active = False + self._update_quiet = False + if not quiet: + self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM) + return + self._begin_update(manifest) + + def _begin_update(self, manifest): + if self.busy: + self._update_active = False + self._update_quiet = False + messagebox.showinfo(T("Обновление"), + T("Дождитесь окончания текущей операции"), + parent=self) + return + self.busy = True + self._cancel = threading.Event() + self.btn_play.set_state("disabled") + self.btn_cancel.configure(state="normal") + self.btn_cancel.pack() + self.progress.set(0) + self.lbl_status.configure(text=T("Скачивание обновления"), fg=DIM) + threading.Thread(target=self._update_download_bg, args=(manifest,), + daemon=True).start() + + def _update_download_bg(self, manifest): + try: + path = selfupdate.download( + manifest, + progress=lambda d, t: self.ui_q.put(("update_progress", + d, t)), + cancel=self._cancel) + self.ui_q.put(("update_ready", path)) + except http.Cancelled: + self.ui_q.put(("update_fail", T("Отменено"))) + except Exception as e: # noqa: BLE001 + self.ui_q.put(("update_fail", str(e))) + + def _update_reset(self): + """Снимает состояние занятости обновления.""" + self._update_active = False + self._update_quiet = False + self.busy = False + self._cancel = None + self.btn_play.set_state("normal") + try: + self.btn_cancel.configure(state="normal") + self.btn_cancel.pack_forget() + except tk.TclError: + pass + + def _on_update_ready(self, path): + self._update_reset() + try: + selfupdate.run_installer(path) + except Exception as e: # noqa: BLE001 + messagebox.showerror( + T("Обновление"), + T("Не удалось запустить установщик: ") + str(e), + parent=self) + return + self._log(T("Запускается установщик обновления…")) + self.lbl_status.configure( + text=T("Запускается установщик обновления…"), fg=DIM) + # установщик заменит файлы лаунчера — выходим, пока файлы не заняты + self.after(800, self._on_close) + + def _on_update_fail(self, text): + self._update_reset() + self.progress.set(0) + self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM) + self._log(T("Обновление не установлено: ") + text, "warn") + if text != T("Отменено"): + messagebox.showerror(T("Обновление"), text, parent=self) + + def _open_about(self): + d = self._dialog(T("О программе"), width=360) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=16, pady=12) + tk.Label(body, text=f"PixelLauncher {LAUNCHER_VERSION}", bg=BG, + fg=TEXT, font=("Segoe UI", 12, "bold")).pack(anchor="w") + txt = T("Компактный оффлайн-лаунчер Minecraft\nв стиле LiteLauncher.\n\n" + "• Все версии из манифеста Mojang\n" + "• Автодокачка файлов с проверкой SHA-1\n" + "• Автоподбор Java, копии миров\n" + "• Без сторонних библиотек") + tk.Label(body, text=txt, bg=BG, fg=DIM, font=("Segoe UI", 9), + justify="left").pack(anchor="w", pady=(6, 0)) + links = tk.Frame(body, bg=BG) + links.pack(anchor="w", pady=(10, 0)) + for name, url in (("minecraft.net", "https://www.minecraft.net"), + (T("Манифест Mojang"), + "https://launchermeta.mojang.com/mc/game/" + "version_manifest_v2.json")): + l = tk.Label(links, text=name, bg=BG, fg=OK, cursor="hand2", + font=("Segoe UI", 9, "underline")) + l.pack(side="left", padx=(0, 12)) + l.bind("", lambda e, u=url: webbrowser.open(u)) + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(14, 0)) + self._btn(btns, T("Проверить обновление…"), + lambda: (d.destroy(), self._check_update()) + ).pack(side="left") + self._btn(btns, T("Закрыть"), d.destroy, primary=True).pack(side="right") + self._finish_dialog(d) + + def _pick_java_file(self, var): + path = filedialog.askopenfilename( + parent=self, title=T("Выберите java.exe"), + filetypes=[(T("Java"), "java.exe java"), (T("Все файлы"), "*.*")]) + if path: + var.set(path) + + def _pick_dir(self, var): + path = filedialog.askdirectory(parent=self, title=T("Папка игры")) + if path: + var.set(path) + + # ------------------------------------------------------------ аккаунты + def _ask_text(self, title, label, initial=""): + """Модальный ввод строки. Возвращает текст или None (отмена).""" + d = self._dialog(title, width=340) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + tk.Label(body, text=label, bg=BG, fg=DIM, anchor="w", + font=("Segoe UI", 9)).pack(anchor="w") + var = tk.StringVar(value=initial) + e = tk.Entry(body, textvariable=var, font=("Segoe UI", 10), + bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid", + bd=1, highlightbackground=INPUT_BD, highlightthickness=1) + e.pack(fill="x", ipady=2, pady=(6, 0)) + e.focus_set() + result = [None] + + def ok(): + v = var.get().strip() + if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", v): + messagebox.showwarning( + "PixelLauncher", + T("Ник: 3-16 символов, латиница, цифры и _"), parent=d) + return + result[0] = v + d.destroy() + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(12, 0)) + self._btn(btns, T("Сохранить"), ok, primary=True).pack(side="right") + self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6)) + d.bind("", lambda e: ok()) + self._finish_dialog(d) + self.wait_window(d) + return result[0] + + def _switch_account(self, nick): + """Делает ник активным аккаунтом и сохраняет настройки.""" + self.settings["active_account"] = nick + self.settings["username"] = nick + config.save_settings(self.settings) + self._emit("account_switched", username=str(nick)) + self.var_nick.set(nick) + self.lbl_nick.configure(text=nick) + self._log(T("Аккаунт переключён: ") + nick, "ok") + threading.Thread(target=self._load_head_bg, args=(nick,), + daemon=True).start() + + def _open_accounts(self): + d = self._dialog(T("Аккаунты"), width=380) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + + lb = tk.Listbox(body, height=8, bg=INPUT, fg=TEXT, selectbackground=GREEN, + selectforeground="#0d1a0d", relief="solid", bd=1, + highlightthickness=0, font=("Consolas", 10), + exportselection=False) + lb.pack(fill="both", expand=True) + + def refresh(): + lb.delete(0, "end") + active = self.settings.get("active_account") + for a in self.settings.get("accounts") or []: + lb.insert("end", ("● " if a == active else " ") + a) + if lb.size(): + lb.selection_set(0) + + refresh() + + def selected(): + sel = lb.curselection() + accs = self.settings.get("accounts") or [] + if not sel or sel[0] >= len(accs): + return None + return accs[sel[0]] + + def switch(_=None): + nick = selected() + if nick: + self._switch_account(nick) + d.destroy() + + def add(): + nick = self._ask_text(T("Добавить аккаунт…"), T("Ник:")) + if not nick: + return + accs = self.settings.get("accounts") or [] + if nick not in accs: + accs.append(nick) + self.settings["accounts"] = accs + config.save_settings(self.settings) + refresh() + + def remove(): + nick = selected() + if not nick: + return + accs = self.settings.get("accounts") or [] + if len(accs) <= 1: + messagebox.showwarning( + "PixelLauncher", + T("Нельзя удалить последний аккаунт"), parent=d) + return + accs.remove(nick) + self.settings["accounts"] = accs + if self.settings.get("active_account") == nick: + self._switch_account(accs[0]) + config.save_settings(self.settings) + refresh() + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(10, 0)) + self._btn(btns, T("Выбрать"), switch, primary=True).pack(side="right") + self._btn(btns, T("Удалить"), remove).pack(side="right", padx=(0, 6)) + self._btn(btns, T("Добавить аккаунт…"), add).pack(side="left") + lb.bind("", switch) + self._finish_dialog(d) + + # ------------------------------------------------------------ Fabric/Quilt + def _open_fabric(self): + if self.busy: + return + d = self._dialog(T("Установка лоадера"), width=500) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + + var_loader = tk.StringVar(value="fabric") + row0 = tk.Frame(body, bg=BG) + row0.pack(fill="x", pady=(0, 6)) + tk.Label(row0, text=T("Лоадер:"), bg=BG, fg=DIM, width=14, anchor="w", + font=("Segoe UI", 9)).pack(side="left") + for code, name in (("fabric", T("Fabric")), ("quilt", T("Quilt")), + ("forge", T("Forge")), ("neoforge", T("NeoForge"))): + tk.Radiobutton(row0, text=name, variable=var_loader, value=code, + command=lambda: load_list(), bg=BG, fg=TEXT, + selectcolor=INPUT, activebackground=BG, + activeforeground=TEXT, highlightthickness=0, + font=("Segoe UI", 9)).pack(side="left", padx=(0, 6)) + + tk.Label(body, text=T("Игровая версия:"), bg=BG, fg=DIM, anchor="w", + font=("Segoe UI", 9)).pack(anchor="w", pady=(4, 2)) + lb = tk.Listbox(body, height=9, bg=INPUT, fg=TEXT, selectbackground=GREEN, + selectforeground="#0d1a0d", relief="solid", bd=1, + highlightthickness=0, font=("Consolas", 9), + exportselection=False) + lb.pack(fill="both", expand=True) + + row1 = tk.Frame(body, bg=BG) + row1.pack(fill="x", pady=(8, 0)) + tk.Label(row1, text=T("Версия лоадера:"), bg=BG, fg=DIM, width=14, + anchor="w", font=("Segoe UI", 9)).pack(side="left") + combo = ttk.Combobox(row1, state="readonly", width=24, + font=("Segoe UI", 9)) + combo.pack(side="left") + + status = tk.Label(body, text="", bg=BG, fg=FAINT, anchor="w", + font=("Segoe UI", 8)) + status.pack(fill="x", pady=(6, 0)) + note = tk.Label(body, text="", bg=BG, fg=FAINT, + anchor="w", font=("Segoe UI", 8)) + note.pack(anchor="w", pady=(2, 0)) + + q = queue.Queue() + rows = [] + gen = [0] # номер запроса версий лоадера (защита от гонки) + FML = ("forge", "neoforge") + + def set_note(): + if var_loader.get() in FML: + note.configure(text=T("Инсталлятор сам скачает библиотеки и " + "профиль — может занять несколько " + "минут.")) + else: + note.configure(text=T("Ставится только JSON профиля — файлы " + "докачаются при запуске.")) + + def ask_loads(gv): + """Версии Forge/NeoForge для выбранной игры.""" + gen[0] += 1 + my, loader = gen[0], var_loader.get() + + def bg(): + try: + q.put(("loads", loader, gv, my, + forge_mod.loader_versions(loader, gv))) + except Exception as e: # noqa: BLE001 + q.put(("loads", loader, gv, my, [])) + q.put(("list_err", loader, str(e))) + threading.Thread(target=bg, daemon=True).start() + + def on_mc(_e=None): + if var_loader.get() not in FML: + return + sel = lb.curselection() + if not sel or sel[0] >= len(rows): + return + gv = rows[sel[0]] + combo["values"] = [] + combo.set("") + status.configure(text=T("Загрузка списка версий лоадера..."), + fg=FAINT) + ask_loads(gv) + + lb.bind("<>", on_mc) + + def load_list(): + loader = var_loader.get() + rows.clear() + lb.delete(0, "end") + combo["values"] = [] + combo.set("") + set_note() + status.configure(text=T("Загрузка списка версий лоадера..."), fg=FAINT) + + def bg(): + try: + if loader in FML: + games = forge_mod.game_versions(loader) + q.put(("list", loader, games, [], True)) + else: + games = mods_mod.game_versions(loader) + loads = mods_mod.loader_versions(loader) + q.put(("list", loader, games, loads, False)) + except Exception as e: # noqa: BLE001 + q.put(("list_err", loader, str(e))) + threading.Thread(target=bg, daemon=True).start() + + def poll(): + try: + while True: + msg = q.get_nowait() + cur = var_loader.get() + if msg[0] == "list": + _, loader, games, loads, wait_mc = msg + if loader != cur: + continue + lb.delete(0, "end") + rows.clear() + for gid, stable in games: + rows.append(gid) + lb.insert("end", gid + ("" if stable else + " " + T("[снапшот]"))) + if rows: + lb.selection_set(0) + if wait_mc: + combo["values"] = [] + status.configure(text="", fg=FAINT) + on_mc() + else: + combo["values"] = loads + if loads: + combo.current(0) + status.configure(text="", fg=FAINT) + elif msg[0] == "list_err": + if msg[1] != cur: + continue + status.configure( + text=T("Не удалось получить список версий " + "лоадера: ") + msg[2], fg=ERR) + elif msg[0] == "loads": + _, loader, gv, my, loads = msg + if loader != cur or my != gen[0]: + continue + sel = lb.curselection() + if not sel or sel[0] >= len(rows) or rows[sel[0]] != gv: + continue + combo["values"] = loads + if loads: + combo.current(0) + status.configure(text="", fg=FAINT) + else: + status.configure( + text=T("Нет версий лоадера для этой игры"), + fg=ERR) + elif msg[0] == "log": + status.configure(text=msg[1], fg=FAINT) + elif msg[0] == "installed": + vid = msg[1] + if vid not in self.versions: + pos = len([x for x in self.versions + if x.startswith("latest-")]) + self.versions.insert(pos, vid) + self.ver_meta[vid] = ("local", "") + self._pick_version(vid) + self._log(T("Установлено: ") + vid, "ok") + d.destroy() + return + elif msg[0] == "install_err": + status.configure(text=T("Ошибка: ") + msg[1], fg=ERR) + btn_install.configure(state="normal") + except queue.Empty: + pass + if d.winfo_exists(): + d.after(120, poll) + + def install(): + sel = lb.curselection() + if not sel or sel[0] >= len(rows): + return + gv = rows[sel[0]] + lv = combo.get().strip() + loader = var_loader.get() + gd = self._game_dir() + if loader in FML and not lv: + status.configure(text=T("Выберите версию лоадера"), fg=ERR) + return + btn_install.configure(state="disabled") + status.configure(text=T("Установка..."), fg=FAINT) + + def bg(): + try: + if loader in FML: + vid = forge_mod.install( + gd, loader, gv, lv or None, + log=lambda m: q.put(("log", m))) + else: + vid = mods_mod.install(gd, loader, gv, lv or None) + q.put(("installed", vid)) + except Exception as e: # noqa: BLE001 + q.put(("install_err", str(e))) + threading.Thread(target=bg, daemon=True).start() + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(10, 0)) + btn_install = self._btn(btns, T("Установить"), install, primary=True) + btn_install.pack(side="right") + self._btn(btns, T("Закрыть"), d.destroy).pack(side="right", padx=(0, 6)) + d.after(100, poll) + load_list() + self._finish_dialog(d) + return d + + # ------------------------------------------------------------ очистка + def _open_cleanup(self): + if self.busy: + return + d = self._dialog(T("Очистка версий"), width=540) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=14, pady=10) + + lb = tk.Listbox(body, height=13, bg=INPUT, fg=TEXT, selectbackground=GREEN, + selectforeground="#0d1a0d", relief="solid", bd=1, + highlightthickness=0, font=("Consolas", 9), + exportselection=False) + lb.pack(fill="both", expand=True) + info = tk.Label(body, text=T("Считаю размеры..."), bg=BG, fg=FAINT, + anchor="w", font=("Segoe UI", 8)) + info.pack(fill="x", pady=(6, 0)) + + q = queue.Queue() + rows = [] + + def scan(): + def bg(): + found = [] + vroot = os.path.join(self._game_dir(), "versions") + try: + names = sorted(os.listdir(vroot)) + except OSError: + names = [] + for name in names: + path = os.path.join(vroot, name) + if not os.path.isdir(path): + continue + size = 0 + for root, _, files in os.walk(path): + for f in files: + try: + size += os.path.getsize(os.path.join(root, f)) + except OSError: + pass + found.append((name, size)) + found.sort(key=lambda r: -r[1]) + q.put(("scan", found)) + threading.Thread(target=bg, daemon=True).start() + + def poll(): + try: + while True: + msg = q.get_nowait() + if msg[0] == "scan": + rows[:] = msg[1] + lb.delete(0, "end") + for vid, size in rows: + lb.insert("end", "%-32s %8.1f %s" % + (vid, size / 1048576, T("МБ"))) + total = sum(s for _, s in rows) + info.configure(text="%d · %.0f %s" % + (len(rows), total / 1048576, T("МБ"))) + elif msg[0] == "assets_done": + count, freed = msg[1], msg[2] + info.configure( + text=T("Удалено неиспользуемых ассетов: ") + + "%d · %.1f %s" % (count, freed / 1048576, T("МБ")), + fg=OK) + self._log( + T("Удалено неиспользуемых ассетов: ") + + "%.1f %s" % (freed / 1048576, T("МБ")), "ok") + btn_assets.configure(state="normal") + except queue.Empty: + pass + if d.winfo_exists(): + d.after(150, poll) + + def remove(): + sel = lb.curselection() + if not sel or sel[0] >= len(rows): + return + vid = rows[sel[0]][0] + if vid == self._selected_version_id(): + messagebox.showwarning( + "PixelLauncher", + T("Версия используется как выбранная — смените её " + "в списке."), parent=d) + return + if not messagebox.askyesno( + "PixelLauncher", + T("Удалить версию {v}?").format(v=vid), parent=d): + return + shutil.rmtree(os.path.join(self._game_dir(), "versions", vid), + ignore_errors=True) + self.versions = [v for v in self.versions if v != vid] + del rows[sel[0]] + lb.delete(sel[0]) + self._log(T("Версия удалена: ") + vid, "ok") + + def clean_assets(): + btn_assets.configure(state="disabled") + info.configure(text=T("Поиск неиспользуемых ассетов...")) + + def bg(): + assets = os.path.join(self._game_dir(), "assets") + used = set() + idx_dir = os.path.join(assets, "indexes") + try: + names = os.listdir(idx_dir) + except OSError: + names = [] + for name in names: + if not name.endswith(".json"): + continue + try: + with open(os.path.join(idx_dir, name), + encoding="utf-8") as f: + data = json.load(f) + for obj in (data.get("objects") or {}).values(): + h = obj.get("hash") + if h: + used.add(h) + except Exception: # noqa: BLE001 + continue + freed = count = 0 + obj_dir = os.path.join(assets, "objects") + try: + subs = os.listdir(obj_dir) + except OSError: + subs = [] + for sub in subs: + sp = os.path.join(obj_dir, sub) + if not os.path.isdir(sp): + continue + for f in os.listdir(sp): + if f in used: + continue + fp = os.path.join(sp, f) + try: + freed += os.path.getsize(fp) + os.remove(fp) + count += 1 + except OSError: + pass + q.put(("assets_done", count, freed)) + threading.Thread(target=bg, daemon=True).start() + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(10, 0)) + self._btn(btns, T("Закрыть"), d.destroy, primary=True).pack(side="right") + btn_assets = self._btn(btns, T("Удалить неиспользуемые ассеты"), + clean_assets) + btn_assets.pack(side="right", padx=(0, 6)) + self._btn(btns, T("Удалить выбранную версию"), remove).pack(side="left") + d.after(100, poll) + scan() + self._finish_dialog(d) + + # ------------------------------------------------------------ лог/статус + def _append_log(self, msg, tag=None): + """Добавляет строку в журнал (и в открытое окно журнала).""" + self._log_lines.append((time.strftime("%H:%M:%S"), msg, tag)) + if len(self._log_lines) > 800: + self._log_lines = self._log_lines[-800:] + txt = self._log_txt_widget + if (txt is not None and txt.winfo_exists() + and self._log_view_mode == "session"): + txt.configure(state="normal") + txt.insert("end", time.strftime("%H:%M:%S") + " ", "ts") + txt.insert("end", msg + "\n", tag or ()) + txt.see("end") + txt.configure(state="disabled") + + def _log(self, msg, tag=None): + color = {"ok": OK, "err": ERR, "warn": WARN}.get(tag, DIM) + self.lbl_status.configure(text=msg, fg=color) + self._append_log(msg, tag) + + def _on_game_exit(self, code, path, vid=None, secs=0): + """Реакция на завершение игры: код != 0 — показываем хвост лога.""" + self._game_proc = None + self._emit("game_exit", + profile_name=str(self.settings.get("active_profile") + or ""), + crashed=bool(code), exit_code=code) + ok = not code + if vid: + stats_mod.record_finish(vid, secs, ok, code) + self.ui_q.put(("tray_notify", + T("Игра завершилась") if ok + else T("Игра аварийно завершилась"), + vid or "", not ok)) + if ok: + self._log(T("Игра завершилась"), "warn") + return + self._log(T("Игра аварийно завершилась (код {c}). Хвост лога:").format( + c=code), "err") + tail = [] + try: + with open(path, encoding="utf-8", errors="replace") as f: + tail = [ln.rstrip("\n") for ln in f.readlines()[-30:]] + except OSError: + pass + for ln in tail: + if ln.strip(): + self._append_log(ln, "err") + if path: + self._append_log(T("Полный лог: ") + path, None) + # --- разбор причины вылета + res = crash_mod.analyze(path=path) + if res["kind"] == "oom": + self._append_log(T("Причина: ") + res["exc"], "err") + self._append_log(T("Похоже, не хватило ОЗУ. Увеличьте её в " + "профиле или выберите пресет."), "warn") + elif res["kind"] == "java": + self._append_log(T("Проблема с Java: ") + res["exc"], "err") + elif res["kind"] == "crash" and res["exc"]: + self._append_log(T("Причина: ") + res["exc"] + + ((": " + res["msg"]) if res["msg"] else ""), + "err") + culprit = res.get("culprit") + gd = self._game_dir() + if culprit and culprit.lower().endswith(".jar") and os.path.isfile( + os.path.join(mods_mod.mods_dir(gd), culprit)): + if messagebox.askyesno( + T("Игра аварийно завершилась"), + T("Вероятная причина — мод {m}. Отключить его?").format( + m=culprit)): + if mods_mod.set_enabled(gd, culprit, False): + self._log(T("Мод отключён: ") + culprit, "ok") + if not self._journal_open(): + self._open_log() + + def _append_game_line(self, line): + """Строка живого вывода игры — в открытое окно журнала.""" + txt = self._log_txt_widget + if (txt is None or not txt.winfo_exists() + or self._log_view_mode != "session"): + return + txt.configure(state="normal") + txt.insert("end", line + "\n", "game") + txt.see("end") + try: + n = int(float(txt.index("end-1c").split(".")[0])) + if n > 3000: + txt.delete("1.0", "800.0") + except (ValueError, tk.TclError): + pass + txt.configure(state="disabled") + + # ------------------------------------------------------------ данные + def _load_settings_to_ui(self): + self.var_nick = tk.StringVar(value=self.settings.get("username", "Player")) + self.var_ver = tk.StringVar(value=self.settings.get("version", "latest-release")) + self.lbl_nick.configure(text=self.var_nick.get()) + + def _save_settings(self): + self.settings.update({ + "username": self.var_nick.get().strip(), + "version": self.var_ver.get().strip(), + }) + config.save_settings(self.settings) + + def _game_dir(self): + return self.settings.get("game_dir", config.DEFAULT_GAME_DIR) + + def _ram_max(self): + try: + if os.name == "nt": + import ctypes + + class MEMORYSTATUSEX(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("ullAvailExtendedVirtual", ctypes.c_ulonglong), + ] + m = MEMORYSTATUSEX() + m.dwLength = ctypes.sizeof(MEMORYSTATUSEX) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m)) + return max(4096, min(32768, int(m.ullTotalPhys // (1024 * 1024)))) + except Exception: + pass + return 8192 + + def _open_explorer(self, d): + os.makedirs(d, exist_ok=True) + if os.name == "nt": + os.startfile(d) # noqa: S606 + else: + subprocess.Popen(["xdg-open", d], creationflags=CREATE_NO_WINDOW) + + # ------------------------------------------------------------ галерея + def _open_gallery(self): + """Галерея скриншотов: сетка превью, просмотр, удаление.""" + d = self._dialog(T("Галерея скриншотов"), width=640) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=12, pady=10) + shot_dir = os.path.join(self._game_dir(), "screenshots") + cols, tw, th = 4, 136, 84 + + cv = tk.Canvas(body, bg=BG, highlightthickness=0) + sb = tk.Scrollbar(body, orient="vertical", command=cv.yview) + cv.configure(yscrollcommand=sb.set) + sb.pack(side="right", fill="y") + cv.pack(side="left", fill="both", expand=True) + grid = tk.Frame(cv, bg=BG) + cv_win = cv.create_window((0, 0), window=grid, anchor="nw") + grid.bind("", + lambda e: cv.configure(scrollregion=cv.bbox("all"))) + cv.bind("", + lambda e: cv.itemconfigure(cv_win, width=e.width)) + cv.bind("", + lambda e: cv.yview_scroll(-1 if e.delta > 0 else 1, "units")) + + def list_shots(): + try: + fs = [os.path.join(shot_dir, fn) for fn in os.listdir(shot_dir) + if fn.lower().endswith(".png")] + fs = [p for p in fs if os.path.isfile(p)] + fs.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return fs[:48] + except OSError: + return [] + + photos = [] # ссылки, чтобы Tk не освободил картинки + + def load_thumb(cnv, path): + if not cnv.winfo_exists(): + return + try: + img = tk.PhotoImage(file=path) + k = max(1, (img.width() + tw - 1) // tw, + (img.height() + th - 1) // th) + small = img.subsample(k, k) + photos.append(small) + cnv.create_image(tw // 2, th // 2, image=small) + except tk.TclError: + cnv.create_text(tw // 2, th // 2, text="?", fill=FAINT, + font=("Segoe UI", 14)) + + def open_view(path): + if not os.path.isfile(path): + return + v = tk.Toplevel(self) + v.configure(bg=BG) + v.title(os.path.basename(path)) + vw, vh = 960, 620 + v.geometry(f"{vw}x{vh}+" + f"{self.winfo_rootx() + (self.winfo_width() - vw) // 2}" + f"+{self.winfo_rooty() + (self.winfo_height() - vh) // 2}") + _dark_titlebar(v) + holder = {"img": None} + pic = tk.Label(v, bg="#0c0c0c") + pic.pack(fill="both", expand=True, padx=10, pady=10) + try: + img = tk.PhotoImage(file=path) + k = max(1, (img.width() + vw - 61) // (vw - 40), + (img.height() + vh - 121) // (vh - 100)) + if k > 1: + img = img.subsample(k, k) + holder["img"] = img + pic.configure(image=img) + except tk.TclError as e: # noqa: BLE001 + pic.configure(text=T("Не удалось открыть скриншот") + ": " + + str(e), fg=ERR) + btns = tk.Frame(v, bg=BG) + btns.pack(fill="x", padx=10, pady=(0, 10)) + + def open_file(): + try: + os.startfile(path) # noqa: S606 + except OSError as e: # noqa: BLE001 + self._log(T("Не удалось открыть скриншот") + ": " + + str(e), "err") + + def remove(): + if not messagebox.askyesno( + "PixelLauncher", + T("Удалить скриншот {p}?").format( + p=os.path.basename(path)), parent=v): + return + try: + os.remove(path) + self._log(T("Скриншот удалён: ") + + os.path.basename(path), "ok") + except OSError as e: # noqa: BLE001 + self._log(T("Не удалось удалить скриншот") + ": " + + str(e), "err") + v.destroy() + refresh() + + self._btn(btns, T("Открыть файл"), open_file).pack(side="left") + self._btn(btns, T("Удалить"), remove).pack(side="left", + padx=(6, 0)) + self._btn(btns, T("Закрыть"), v.destroy).pack(side="right") + + def cell(parent, path): + wrap = tk.Frame(parent, bg=INPUT, highlightthickness=1, + highlightbackground=BAR_BD, cursor="hand2") + wrap.pack(side="left", padx=5, pady=5) + cnv = tk.Canvas(wrap, width=tw, height=th, bg="#0c0c0c", + highlightthickness=0) + cnv.pack() + tk.Label(wrap, text=os.path.basename(path), bg=INPUT, fg=DIM, + width=22, anchor="center", + font=("Segoe UI", 7)).pack() + for wgt in (wrap, cnv): + wgt.bind("", lambda e, p=path: open_view(p)) + return cnv + + def refresh(): + for w in grid.winfo_children(): + w.destroy() + files = list_shots() + if not files: + tk.Label(grid, text=T("Скриншотов пока нет"), bg=BG, fg=DIM, + font=("Segoe UI", 10)).pack(pady=(24, 0)) + self._btn(grid, T("Открыть папку"), + lambda: self._open_explorer(shot_dir)).pack( + pady=(10, 0)) + return + row = None + for i, p in enumerate(files): + if i % cols == 0: + row = tk.Frame(grid, bg=BG) + row.pack(fill="x") + cnv = cell(row, p) + # превью грузим по одному — окно не подвисает + grid.after(40 + i * 35, + lambda c=cnv, pp=p: load_thumb(c, pp)) + + refresh() + self._finish_dialog(d) + return d + + # ------------------------------------------------------ перетаскивание + def _enable_dnd(self): + """Приём файлов, брошенных в окно: .mrpack, .jar, .py.""" + if os.name != "nt": + return + try: + WM_DROPFILES = 0x0233 + GWLP_WNDPROC = -4 + WNDPROC = ctypes.WINFUNCTYPE( + ctypes.c_ssize_t, wintypes.HWND, wintypes.UINT, + wintypes.WPARAM, wintypes.LPARAM) + user32 = ctypes.windll.user32 + shell32 = ctypes.windll.shell32 + user32.SetWindowLongPtrW.argtypes = ( + wintypes.HWND, ctypes.c_int, ctypes.c_void_p) + user32.SetWindowLongPtrW.restype = ctypes.c_ssize_t + user32.CallWindowProcW.argtypes = ( + ctypes.c_void_p, wintypes.HWND, wintypes.UINT, + wintypes.WPARAM, wintypes.LPARAM) + user32.CallWindowProcW.restype = ctypes.c_ssize_t + shell32.DragQueryFileW.argtypes = ( + ctypes.c_void_p, wintypes.UINT, wintypes.LPCWSTR, + wintypes.UINT) + shell32.DragQueryFileW.restype = wintypes.UINT + shell32.DragFinish.argtypes = (ctypes.c_void_p,) + shell32.DragAcceptFiles.argtypes = (wintypes.HWND, wintypes.BOOL) + hwnd = wintypes.HWND(self.winfo_id()) + + def take_paths(hglobal): + out = [] + try: + n = shell32.DragQueryFileW(hglobal, 0xffffffff, None, 0) + buf = ctypes.create_unicode_buffer(32768) + for i in range(n): + shell32.DragQueryFileW(hglobal, i, buf, 32768) + out.append(buf.value) + finally: + shell32.DragFinish(hglobal) + return out + + def wnd_proc(h, msg, w, l): + if msg == WM_DROPFILES: + # максимум осторожности: только ctypes, без вызовов + # tkinter — иначе CPython падает (GIL) внутри цикла tcl + try: + files = take_paths(w) + if files: + self._dnd_queue.append(files) + except Exception: # noqa: BLE001 + pass + return 0 + return user32.CallWindowProcW(self._dnd_old, h, msg, w, l) + + self._dnd_proc = WNDPROC(wnd_proc) + self._dnd_queue = [] + self._dnd_old = user32.SetWindowLongPtrW( + hwnd, GWLP_WNDPROC, self._dnd_proc) + shell32.DragAcceptFiles(hwnd, True) + + def poll_dnd(): + if self._dnd_queue: + files = self._dnd_queue.pop(0) + self._on_drop(files) + self.after(200, poll_dnd) + self.after(200, poll_dnd) + except Exception as e: # noqa: BLE001 + try: + self._log("DnD: " + str(e), "warn") + except Exception: # noqa: BLE001 + pass + + def _on_drop(self, files): + """Что делать с файлами, брошенными в окно.""" + for p in files: + name = os.path.basename(p) + low = p.lower() + if low.endswith(".mrpack"): + self._log(T("Импорт модпака: ") + name) + self._import_pack(p) + elif low.endswith(".jar"): + self._add_mod_file(p) + elif low.endswith(".py"): + self._add_plugin_file(p) + else: + self._log(T("Файл не подходит для лаунчера: ") + name, + "warn") + + def _add_mod_file(self, path): + try: + shutil.copy2(path, os.path.join( + mods_mod.mods_dir(self._game_dir()), os.path.basename(path))) + self._log(T("Мод добавлен: ") + os.path.basename(path), "ok") + except OSError as e: # noqa: BLE001 + self._log(T("Не удалось добавить мод") + ": " + str(e), "err") + + def _add_plugin_file(self, path): + try: + shutil.copy2(path, os.path.join(plugins_mod.PLUGIN_DIR, + os.path.basename(path))) + self._log(T("Плагин добавлен: ") + os.path.basename(path), "ok") + plugins_mod.scan() + except OSError as e: # noqa: BLE001 + self._log(T("Не удалось добавить плагин") + ": " + str(e), "err") + + # ------------------------------------------------------------ версии + def _load_versions_bg(self): + try: + gd = self._game_dir() + m = manifest_mod.get_manifest(gd, net=True) + latest = m.get("latest", {}) + ids = [] + if latest.get("release"): + ids.append("latest-release (" + latest["release"] + ")") + if latest.get("snapshot"): + ids.append("latest-snapshot (" + latest["snapshot"] + ")") + releases, snaps, other = [], [], [] + for v in m.get("versions", []): + vid, vtype = v.get("id"), v.get("type", "release") + date = (v.get("releaseTime") or "")[:10] + self.ver_meta[vid] = (vtype, date) + (releases if vtype == "release" else + snaps if vtype == "snapshot" else other).append(vid) + # локальные версии (Fabric/Quilt) — сразу после latest-записей + known = set(releases + snaps + other) + extra = [v for v in manifest_mod.local_versions(gd) if v not in known] + for v in extra: + self.ver_meta[v] = ("local", "") + pos = len([x for x in ids if x.startswith("latest-")]) + ids[pos:pos] = extra + ids += releases + snaps + other + self.versions = ids + news = [(v.get("id"), v.get("type", "release"), + (v.get("releaseTime") or "")[:10]) + for v in m.get("versions", [])[:3]] + self.ui_q.put(("versions", ids, latest, news)) + except Exception as e: # noqa: BLE001 + self.ui_q.put(("log", T("Не удалось загрузить список версий: ") + + str(e), "err")) + + def _apply_versions(self, ids, latest, news=None): + self._news = news + cur = self.var_ver.get() + if self._force_version: + if self._force_version in ids: + cur = self._force_version + self.var_ver.set(cur) + self._save_settings() + self._force_version = None + if not cur or (cur not in ids and not cur.startswith("latest-")): + self.var_ver.set(ids[0] if ids else "") + self._fill_news(news) + self._update_pill() + self._log(T("Загружено версий: ") + str(len(ids)), "ok") + + def _reload_versions(self): + if self.busy: + return + manifest_mod.invalidate() # следующая загрузка пойдёт в сеть + threading.Thread(target=self._load_versions_bg, daemon=True).start() + + def _update_pill(self): + vid = self._selected_version_id() + prof = self.settings.get("active_profile") or "" + text = f"Minecraft {vid}" + if prof: + text = f"{prof} · {text}" + self.pill.set_text(text) + + def _selected_version_id(self): + vsel = self.var_ver.get().strip() + latest = self._latest or {} + if vsel.startswith("latest-release"): + return latest.get("release", "latest-release") + if vsel.startswith("latest-snapshot"): + return latest.get("snapshot", "latest-snapshot") + return vsel or "…" + + def _fill_news(self, news): + """Заполняет ленту новостей последними версиями из манифеста.""" + for w in self.news_rows.winfo_children(): + w.destroy() + if not news: + tk.Label(self.news_rows, text=T("загрузка…"), bg=BG, fg=FAINT, + font=("Segoe UI", 8)).pack() + return + for vid, vtype, date in news: + row = tk.Frame(self.news_rows, bg=BG, cursor="hand2") + row.pack(pady=1) + color = {"release": OK, "snapshot": WARN}.get(vtype, FAINT) + cells = [ + tk.Label(row, text=vid, bg=BG, fg=TEXT, + font=("Segoe UI", 9, "bold")), + tk.Label(row, text=T(TYPE_RU.get(vtype, vtype)), bg=BG, fg=color, + font=("Segoe UI", 8)), + tk.Label(row, text=date, bg=BG, fg=FAINT, + font=("Segoe UI", 8)), + ] + for i, c in enumerate(cells): + c.pack(side="left", padx=(0 if i == 0 else 10, 0)) + c.bind("", lambda e, v=vid: self._pick_version(v)) + + def _pick_version(self, vid): + """Выбор версии из ленты новостей или диалога.""" + if self.busy: + return + self.var_ver.set(vid) + self._save_settings() + self._update_pill() + self._log(T("Версия выбрана: ") + vid, "ok") + + # ------------------------------------------------------------ запуск + def _on_play(self): + if self.busy: + return + nick = self.var_nick.get().strip() + if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick): + messagebox.showwarning("PixelLauncher", + T("Ник: 3-16 символов, латиница, цифры и _")) + return + self._save_settings() + self._begin_work(nick, do_launch=True) + + def _on_download(self): + if self.busy: + return + self._save_settings() + self._begin_work() + + def _on_verify(self): + """Полная проверка SHA-1 всех файлов выбранной версии.""" + if self.busy: + return + self._save_settings() + self._begin_work(verify=True) + + def _on_cancel(self): + """Останавливает идущую загрузку.""" + if not self.busy or self._cancel is None: + return + self._cancel.set() + self.btn_cancel.configure(state="disabled") + self._log(T("Отмена..."), "warn") + + def _auto_ram(self): + """ОЗУ по умолчанию: половина памяти системы (512-кратно).""" + total = self._ram_max() + return int(max(2048, min(total // 2, 16384)) // 512 * 512) + + def _begin_work(self, nick=None, do_launch=False, verify=False): + self.busy = True + self._cancel = threading.Event() + self.btn_play.set_state("disabled") + self.btn_cancel.configure(state="normal") + self.btn_cancel.pack() + self.progress.set(0) + threading.Thread(target=self._work_bg, args=(nick, do_launch, verify), + daemon=True).start() + + def _work_bg(self, nick, do_launch, verify=False): + log = lambda m, t=None: self.ui_q.put(("log", m, t)) # noqa: E731 + prog = lambda d, t, title="": self.ui_q.put(("progress", d, t, title)) # noqa: E731 + cancel = self._cancel + try: + gd = self._game_dir() + os.makedirs(gd, exist_ok=True) + + log(T("Получение манифеста версий...")) + manifest_mod.get_manifest(gd, net=True) + vid = self._selected_version_id() + log(T("Версия: ") + vid) + + # авто-бэкап мира перед запуском (по настройке) + if do_launch and self.settings.get("auto_backup"): + try: + self._backup_worlds(gd, log) + except Exception as be: # noqa: BLE001 + log(T("Ошибка: ") + str(be), "warn") + + ver = version_mod.load(gd, vid, net=True) + if not ver.main_class: + raise RuntimeError("Не удалось разобрать JSON версии") + + required = max(ver.java_major, 8) + preferred = self.settings.get("java_path", "") + if not preferred: + # уже скачанная нами Java (Adoptium) — приоритетнее прочих + preferred = java_fetch.find_installed( + os.path.join(gd, ".launcher", "runtime"), required) + jpath, jver, ok = java_mod.pick_java( + required, preferred, + cache_path=os.path.join(gd, ".launcher", "java_cache.json")) + if not jpath: + log(T("Java не найдена, скачиваю..."), "warn") + jpath = java_fetch.ensure_java( + required, os.path.join(gd, ".launcher", "runtime"), + progress=lambda d, t: prog(d, t, "Java"), + log=lambda m, t=None: self.ui_q.put(("log", m, t)), + cancel=cancel) + jver = java_mod.java_major(jpath) or required + ok = jver >= required + if ok: + log(T("Java {jver} найдена").format(jver=jver), "ok") + else: + log(T("Нужна Java {r}+, найдена {j}. Возможны проблемы.") + .format(r=required, j=jver), "warn") + + if verify: + log(T("Проверка целостности файлов (SHA-1)...")) + else: + log(T("Скачивание файлов игры...")) + errors, natives = downloader.ensure( + ver, + progress=lambda d, t: prog(d, t, T("Файлы")), + asset_progress=lambda d, t: prog(d, t, T("Ассеты")), + log=lambda m: self.ui_q.put(("log", m, None)), + verify=verify, + cancel=cancel, + asset_mirrors=self.settings.get("asset_mirrors") or None, + lib_mirrors=self.settings.get("lib_mirrors") or None) + if errors: + log(T("Ошибок скачивания: ") + str(len(errors)) + " (" + + "; ".join(errors[:2]) + ")", "err") + + if do_launch: + uuid_hex = launch_mod.offline_uuid(nick) + ram = (self._auto_ram() if self.settings.get("auto_ram") + else int(self.settings.get("ram", 2048))) + cmd = ver.build_command(jpath, nick, uuid_hex, ram, + self.settings.get("jvm_args", "")) + # размер окна/полный экран из профиля + try: + ww = int(self.settings.get("win_w") or 0) + wh = int(self.settings.get("win_h") or 0) + except (TypeError, ValueError): + ww = wh = 0 + if ww > 0: + cmd += ["--width", str(ww)] + if wh > 0: + cmd += ["--height", str(wh)] + if self.settings.get("fullscreen"): + cmd += ["--fullscreen"] + log_path = os.path.join(gd, ".launcher", "logs", + "game-" + time.strftime("%Y%m%d-%H%M%S") + + ".log") + + def on_line(line): + # не раздуваем очередь, если журнал закрыт и игра шумит + if self.ui_q.qsize() < 3000: + self.ui_q.put(("game_line", line)) + + def on_exit(code): + self.ui_q.put(("game_exit", code, log_path, vid, + int(time.time() - t0))) + + prof = str(self.settings.get("active_profile") or "") or nick + self._emit("before_launch", profile_name=prof, + version=str(vid)) + t0 = time.time() + stats_mod.record_start(vid) + proc = launch_mod.launch( + gd, cmd, + log=lambda m: self.ui_q.put( + ("log", m, "ok" + if m.startswith(T("Игра запущена, PID ")) else None)), + log_path=log_path, on_line=on_line, on_exit=on_exit) + self._game_proc = proc + if self.settings.get("minimize_to_tray_on_launch"): + self.ui_q.put(("minimize",)) + self._emit("after_launch", profile_name=prof, + version=str(vid), pid=proc.pid) + self.ui_q.put(("log", T("Вывод игры пишется в ") + log_path, + None)) + self.ui_q.put(("tray_notify", T("Игра запущена"), vid)) + else: + if verify and not errors: + log(T("Все файлы на месте, повреждений не найдено."), "ok") + else: + log(T("Версия скачана и готова к запуску."), "ok") + except http.Cancelled: + log(T("Загрузка отменена"), "warn") + except Exception as e: # noqa: BLE001 + log(T("Ошибка: ") + str(e), "err") + finally: + self.ui_q.put(("done",)) + + # ------------------------------------------------------------ бэкап + def _on_backup(self): + if self.busy: + return + self.busy = True + self.btn_play.set_state("disabled") + self.progress.set(0) + threading.Thread(target=self._backup_bg, daemon=True).start() + + def _backup_worlds(self, gd, log, progress=None): + """ZIP-копия папки saves. Возвращает каталог backups.""" + import zipfile + saves = os.path.join(gd, "saves") + if not os.path.isdir(saves): + raise RuntimeError(T("Папка миров (saves) не найдена")) + out_dir = os.path.join(gd, "backups") + os.makedirs(out_dir, exist_ok=True) + path = os.path.join(out_dir, + "saves-" + time.strftime("%Y%m%d-%H%M%S") + ".zip") + files = [] + for root, _, fnames in os.walk(saves): + for f in fnames: + fp = os.path.join(root, f) + files.append((fp, os.path.relpath(fp, saves))) + log(T("Создание резервной копии: ") + str(len(files)) + + T(" файлов...")) + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + for i, (fp, rel) in enumerate(files, 1): + try: + z.write(fp, rel) + except OSError: + continue + if progress and (i % 10 == 0 or i == len(files)): + progress(i, len(files)) + size_mb = os.path.getsize(path) / (1024 * 1024) + log(T("Копия создана: ") + os.path.basename(path) + f" ({size_mb:.1f} " + + T("МБ") + ")", "ok") + return out_dir + + def _backup_bg(self): + log = lambda m, t=None: self.ui_q.put(("log", m, t)) # noqa: E731 + try: + out_dir = self._backup_worlds( + self._game_dir(), log, + progress=lambda i, n: self.ui_q.put( + ("progress", i, n, T("Копирование")))) + self.ui_q.put(("open_dir", out_dir)) + except Exception as e: # noqa: BLE001 + log(T("Ошибка: ") + str(e), "err") + finally: + self.ui_q.put(("done",)) + + def _on_close(self): + if self._close_menu_if_open(): + # меню модальное: tk_popup вернётся после unpost, + # и только тогда уничтожаем окно + self._close_after_menu = True + return + if self._tray is not None: + try: + self._tray.stop() + except Exception: # noqa: BLE001 + pass + self._tray = None + self._emit("launcher_closing") + protocol_mod.clear_instance() + try: + self._save_settings() + except Exception: + pass + self._cancel_afters(self) + self.destroy() + + # ------------------------------------------------------------ poll + def _poll_ui(self): + try: + while True: + msg = self.ui_q.get_nowait() + kind = msg[0] + if kind == "log": + self._log(msg[1], msg[2] if len(msg) > 2 else None) + elif kind == "versions": + self._latest = msg[2] + self._apply_versions(msg[1], msg[2], + msg[3] if len(msg) > 3 else None) + if not getattr(self, "_mod_upd_done", False): + self._mod_upd_done = True + self._startup_mod_updates() + elif kind == "progress": + _, done, total, title = msg + pct = (done / total * 100) if total else 0 + self.progress.set(pct) + if title: + self.lbl_status.configure(text=f"{title} {done}/{total}", + fg=DIM) + elif kind == "head": + self._show_head(msg[1], msg[2]) + elif kind == "open_dir": + self._open_explorer(msg[1]) + elif kind == "minimize": + self._minimize() + elif kind == "game_line": + self._append_game_line(msg[1]) + elif kind == "game_exit": + self._on_game_exit( + msg[1], msg[2] if len(msg) > 2 else "", + msg[3] if len(msg) > 3 else None, + msg[4] if len(msg) > 4 else 0) + elif kind == "tray": + self._tray_event(msg[1]) + elif kind == "tray_notify": + self._tray_notify(msg[1], msg[2], + msg[3] if len(msg) > 3 else False) + elif kind == "skin_path": + render = getattr(self, "_skin_render", None) + if render: + render(msg[1]) + elif kind == "use_version": + self.var_ver.set(msg[1]) + self._save_settings() + self._update_pill() + elif kind == "reload_versions": + self._reload_versions() + elif kind == "update_found": + self._on_update_found(msg[1], msg[2], msg[3]) + elif kind == "update_progress": + _, done, total = msg + pct = (done / total * 100) if total else 0 + self.progress.set(pct) + self.lbl_status.configure( + text=f"{T('Скачивание обновления')} " + f"{pct:.0f}%", fg=DIM) + elif kind == "update_ready": + self._on_update_ready(msg[1]) + elif kind == "update_fail": + self._on_update_fail(msg[1]) + elif kind == "done": + self.busy = False + self._cancel = None + self.btn_play.set_state("normal") + try: + self.btn_cancel.configure(state="normal") + self.btn_cancel.pack_forget() + except tk.TclError: + pass + self.progress.set(100) + self.after(2500, self._reset_progress) + except queue.Empty: + pass + except tk.TclError: + return # окно уничтожено — не перепланируем + try: + self.after(100, self._poll_ui) + except tk.TclError: + pass + + @staticmethod + def _cancel_afters(app): + """Снимает все отложенные callback'и Tk (чтобы не сыпались bgerror + уже после destroy).""" + try: + for aid in app.tk.splitlist(app.tk.call("after", "info")): + try: + app.tk.call("after", "cancel", aid) + except tk.TclError: + pass + except tk.TclError: + pass + + def _reset_progress(self): + if not self.busy: + self.progress.set(0) + self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM) + + +def run(select_version=None, protocol_url=None, select_profile=None): + if os.name == "nt": + try: + import ctypes + ctypes.windll.shcore.SetProcessDpiAwareness(1) + except Exception: + pass + settings = config.load_settings() + if not settings.get("onboarding_done"): + from . import onboarding + onboarding.run(settings) # первый запуск: язык, ник, ОЗУ + config.save_settings(settings) + if protocol_url and protocol_mod.other_instance_running(): + protocol_mod.queue_url(protocol_url) # лаунчер уже открыт — в очередь + return + protocol_mod.mark_instance() + app = App(select_version=select_version, protocol_url=protocol_url, + select_profile=select_profile) + app.mainloop() + protocol_mod.clear_instance() diff --git a/launcher/version.py b/launcher/version.py new file mode 100644 index 0000000..c75dfd9 --- /dev/null +++ b/launcher/version.py @@ -0,0 +1,265 @@ +"""Разбор JSON версии Minecraft: библиотеки, правила, аргументы, classpath.""" + +import os +import re +import shlex +import sys + +from . import manifest as manifest_mod + +LIB_BASE = "https://libraries.minecraft.net/" + + +def os_name(): + if sys.platform.startswith("win"): + return "windows" + if sys.platform == "darwin": + return "osx" + return "linux" + + +def _arch(): + import struct + return "x86_64" if struct.calcsize("P") * 8 == 64 else "x86" + + +def _rule_matches(osblock, cur_os, cur_arch, os_version): + if not osblock: + return True + name = osblock.get("name") + if name and name != cur_os: + return False + arch = osblock.get("arch") + if arch and arch != cur_arch: + return False + ver = osblock.get("version") + if ver and cur_os == "osx": + try: + if not re.search(ver, os_version): + return False + except re.error: + pass + return True + + +def rules_allow(rules, cur_os=None, cur_arch=None, os_version=""): + """Правила Mojang: без правил — разрешено; иначе последнее подходящее.""" + if not rules: + return True + cur_os = cur_os or os_name() + cur_arch = cur_arch or _arch() + allowed = False + for rule in rules: + if _rule_matches(rule.get("os"), cur_os, cur_arch, os_version): + if rule.get("features"): # demo и пр. — не поддерживаем + continue + allowed = rule.get("action") == "allow" + return allowed + + +def maven_to_path(name): + """org.lwjgl:lwjgl:2.9.4-nightly -> org/lwjgl/lwjgl/2.9.4-nightly/lwjgl-2.9.4-nightly.jar""" + parts = name.split(":") + if len(parts) < 3: + return "" + group, artifact, version = parts[0], parts[1], parts[2] + classifier = parts[3] if len(parts) > 3 else None + fname = f"{artifact}-{version}" + (f"-{classifier}" if classifier else "") + ".jar" + return "/".join(group.split(".") + [artifact, version, fname]) + + +def _flatten_args(items, cur_os): + out = [] + for it in items or []: + if isinstance(it, str): + out.append(it) + elif isinstance(it, dict) and rules_allow(it.get("rules"), cur_os): + v = it.get("value") + if isinstance(v, list): + out.extend(v) + elif v: + out.append(v) + return out + + +class Version: + def __init__(self, game_dir, data): + self.game_dir = game_dir + self.id = data.get("id", "unknown") + self.type = data.get("type", "release") + self.main_class = data.get("mainClass", "") + self.java_major = (data.get("javaVersion") or {}).get("majorVersion", 8) + + self.asset_index = data.get("assetIndex") or {} + self.logging = (data.get("logging") or {}).get("client") or {} + + client = (data.get("downloads") or {}).get("client") or {} + self.client_url = client.get("url") + self.client_sha1 = client.get("sha1") + self.client_size = client.get("size") + + cur_os = os_name() + self.libraries = [] # {"path", "url", "sha1", "size"} — всё что скачать + self.natives_jars = [] # пути (rel) к jar'ам с нативами + self.classpath = [] # rel-пути для -cp (включая natives-jar'ы) + self._parse_libraries(data.get("libraries") or [], cur_os) + + if "arguments" in data: + self.jvm_args = _flatten_args((data.get("arguments") or {}).get("jvm"), cur_os) + self.game_args = _flatten_args((data.get("arguments") or {}).get("game"), cur_os) + else: + self.jvm_args = ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"] + legacy = data.get("minecraftArguments") or "" + self.game_args = shlex.split(legacy) if legacy else [] + + # ------------------------------------------------------------------ + def _parse_libraries(self, libs, cur_os): + seen_paths = set() + for lib in libs: + if not rules_allow(lib.get("rules"), cur_os): + continue + name = lib.get("name", "") + dl = lib.get("downloads") or {} + artifact = dl.get("artifact") + + if artifact and artifact.get("path"): + self._add_lib(artifact["path"], + artifact.get("url") or LIB_BASE + artifact["path"], + artifact.get("sha1"), seen_paths, + artifact.get("size")) + elif name and not lib.get("natives"): + # Старый стиль (без downloads): собираем путь из maven-имени + rel = maven_to_path(name) + if rel: + base = lib.get("url") or LIB_BASE + self._add_lib(rel, base + rel, None, seen_paths) + + nats = lib.get("natives") or {} + cls_key = nats.get(cur_os) + if cls_key: + cls_key = cls_key.replace("${arch}", "64" if _arch() == "x86_64" else "32") + classifiers = dl.get("classifiers") or {} + cj = classifiers.get(cls_key) + if cj and cj.get("path"): + self._add_lib(cj["path"], + cj.get("url") or LIB_BASE + cj["path"], + cj.get("sha1"), seen_paths, cj.get("size")) + self.natives_jars.append(cj["path"]) + else: + # Нет downloads.classifiers — собираем путь вручную + if name and cls_key: + parts = name.split(":") + if len(parts) == 3: + rel = maven_to_path(name + ":" + cls_key) + if rel: + self._add_lib(rel, LIB_BASE + rel, None, seen_paths) + self.natives_jars.append(rel) + + def _add_lib(self, rel, url, sha1, seen, size=None): + if rel in seen: + return + seen.add(rel) + self.libraries.append({"path": rel, "url": url, "sha1": sha1, + "size": size}) + self.classpath.append(rel) + + # ------------------------------------------------------------------ + def libraries_dir(self): + return os.path.join(self.game_dir, "libraries") + + def natives_dir(self): + return os.path.join(self.game_dir, "versions", self.id, "natives-" + os_name()) + + def client_jar_path(self): + return os.path.join(self.game_dir, "versions", self.id, self.id + ".jar") + + def assets_dir(self): + return os.path.join(self.game_dir, "assets") + + def classpath_str(self): + libs = self.libraries_dir() + entries = [os.path.join(libs, *p.split("/")) for p in self.classpath] + entries.append(self.client_jar_path()) + return os.pathsep.join(entries) + + # ------------------------------------------------------------------ + def build_command(self, java, username, uuid_hex, ram_mb, extra_jvm=""): + gd = self.game_dir + subst = { + "auth_player_name": username, + "version_name": self.id, + "game_directory": gd, + "assets_root": self.assets_dir(), + "assets_index_name": self.asset_index.get("id", ""), + "auth_uuid": uuid_hex, + "auth_access_token": "0", + "auth_session": "0", + "user_type": "msa", + "version_type": self.type, + "natives_directory": self.natives_dir(), + "launcher_name": "PixelLauncher", + "launcher_version": "2.1.0", + "classpath": self.classpath_str(), + "classpath_separator": os.pathsep, + "library_directory": self.libraries_dir(), + "user_properties": "{}", + "clientid": "0", + "auth_xuid": "0", + "game_assets": os.path.join(self.assets_dir(), "virtual", + self.asset_index.get("id", "")), + "auth_local_player_name": username, + } + + def sub(s): + return re.sub(r"\$\{(\w+)\}", lambda m: subst.get(m.group(1), m.group(0)), s) + + cmd = [java] + cmd.append(f"-Xmx{ram_mb}M") + if ram_mb >= 1024: + cmd.append(f"-Xms{min(512, ram_mb // 2)}M") + if self.logging.get("file", {}).get("id") and self.logging.get("argument"): + cfg = os.path.join(self.assets_dir(), "log_configs", + self.logging["file"]["id"]) + if os.path.isfile(cfg): + cmd.append(self.logging["argument"].replace("${path}", cfg)) + cmd.append("-Dminecraft.launcher.brand=PixelLauncher") + if extra_jvm: + cmd.extend(shlex.split(extra_jvm)) + cmd.extend(sub(a) for a in self.jvm_args) + cmd.append(self.main_class) + cmd.extend(sub(a) for a in self.game_args) + return cmd + + +def load(game_dir, vid, net=True): + """Загружает версию, учитывая цепочку inheritsFrom.""" + chain = [] + cur = vid + seen = set() + while cur and cur not in seen: + seen.add(cur) + data = manifest_mod.fetch_version_json(game_dir, cur, net=net) + chain.append(data) + cur = data.get("inheritsFrom") + + # Родитель -> ребёнок: поля ребёнка перекрывают родительские + merged = {} + libs = [] + for data in reversed(chain): + for k, v in data.items(): + if k == "libraries": + continue + merged[k] = v + # Библиотеки: сначала родительские, потом свои (ребёнка) — но чтобы + # библиотеки ребёнка имели приоритет, добавляем их первыми при итерации + # от ребёнка к родителю, см. ниже. + libs.append(data.get("libraries") or []) + + # Собираем: сначала все библиотеки ребёнка (последняя группа), затем родителей + ordered = [] + for group in reversed(libs): + ordered.extend(group) + + merged["libraries"] = ordered + merged["id"] = vid + return Version(game_dir, merged) diff --git a/launcher/worlds.py b/launcher/worlds.py new file mode 100644 index 0000000..234efd9 --- /dev/null +++ b/launcher/worlds.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""Список миров и резервные копии по одному миру. + +Копии хранятся в .launcher/backups/<мир>/ (не путать со старыми +общими saves-*.zip в game_dir/backups — их делает автобэкап). +""" + +import os +import shutil +import time +import zipfile + + +def saves_dir(game_dir): + return os.path.join(game_dir, "saves") + + +def _safe(name): + return "".join(c for c in str(name) + if c not in '\\/:*?"<>|').strip() or "world" + + +def backups_dir(game_dir, world=""): + d = os.path.join(game_dir, ".launcher", "backups") + if world: + d = os.path.join(d, _safe(world)) + return d + + +def _dir_size(path): + total = 0 + for root, _dirs, files in os.walk(path): + for f in files: + try: + total += os.path.getsize(os.path.join(root, f)) + except OSError: + pass + return total + + +def list_worlds(game_dir): + """[{name, size, mtime}] — папки в saves/ (свежие первые).""" + sd = saves_dir(game_dir) + out = [] + if not os.path.isdir(sd): + return out + for name in sorted(os.listdir(sd), key=str.lower): + p = os.path.join(sd, name) + if not os.path.isdir(p): + continue + mtime = 0.0 + for cand in ("level.dat", None): + try: + mtime = os.path.getmtime( + os.path.join(p, cand) if cand else p) + break + except OSError: + continue + out.append({"name": name, "size": _dir_size(p), "mtime": mtime}) + out.sort(key=lambda w: w["mtime"], reverse=True) + return out + + +def list_backups(game_dir, world): + """[{name, size, mtime}] — zip-копии мира (свежие первые).""" + d = backups_dir(game_dir, world) + out = [] + if not os.path.isdir(d): + return out + for name in sorted(os.listdir(d), reverse=True): + p = os.path.join(d, name) + if name.lower().endswith(".zip") and os.path.isfile(p): + try: + out.append({"name": name, "size": os.path.getsize(p), + "mtime": os.path.getmtime(p)}) + except OSError: + pass + return out + + +def prune_backups(game_dir, world, cap): + """Оставляет cap последних копий. Возвращает число удалённых.""" + cap = max(1, int(cap or 1)) + d = backups_dir(game_dir, world) + removed = 0 + for b in list_backups(game_dir, world)[cap:]: + try: + os.remove(os.path.join(d, b["name"])) + removed += 1 + except OSError: + pass + return removed + + +def backup_world(game_dir, world, cap=5, progress=None, log=None): + """ZIP-копия одного мира + удаление старых копий сверх cap. + + Возвращает путь к архиву. + """ + src = os.path.join(saves_dir(game_dir), world) + if not os.path.isdir(src): + raise RuntimeError("мир не найден: " + world) + d = backups_dir(game_dir, world) + os.makedirs(d, exist_ok=True) + stamp = time.strftime("%Y%m%d-%H%M%S") + path = os.path.join(d, f"{_safe(world)}-{stamp}.zip") + n = 1 + while os.path.exists(path): # две копии в одну секунду — суффикс + n += 1 + path = os.path.join(d, f"{_safe(world)}-{stamp}-{n}.zip") + files = [] + for root, _dirs, fnames in os.walk(src): + for f in fnames: + fp = os.path.join(root, f) + files.append((fp, os.path.relpath(fp, src))) + if log: + log(f"Копия мира {world}: {len(files)} файлов…") + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + for i, (fp, rel) in enumerate(files, 1): + try: + z.write(fp, rel) + except OSError: + continue + if progress and (i % 20 == 0 or i == len(files)): + progress(i, len(files)) + prune_backups(game_dir, world, cap) + return path + + +def remove_backup(game_dir, world, name): + """Удаляет одну копию. True при успехе.""" + try: + os.remove(os.path.join(backups_dir(game_dir, world), _safe(name))) + return True + except OSError: + return False + + +def remove_world(game_dir, world): + """Удаляет мир целиком. True при успехе.""" + p = os.path.join(saves_dir(game_dir), world) + if not os.path.isdir(p): + return False + shutil.rmtree(p, ignore_errors=True) + return True diff --git a/main.py b/main.py new file mode 100644 index 0000000..e1e6658 --- /dev/null +++ b/main.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""PixelLauncher — лёгкий Minecraft-лаунчер в духе LiteLauncher. + +Аргументы: + --version — сразу выбрать указанную версию (для ярлыков рабочего стола) + --profile <имя> — применить профиль запуска (ярлыки профилей рабочего стола) + --protocol-url — ссылка из браузера/с сайта; + допустимо и просто первым аргументом: pixellauncher://plugin/x.py +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from launcher.ui import run # noqa: E402 + + +def _select_version(): + args = sys.argv[1:] + if "--version" in args: + i = args.index("--version") + if i + 1 < len(args): + return args[i + 1] + return None + + +def _protocol_url(): + args = sys.argv[1:] + if "--protocol-url" in args: + i = args.index("--protocol-url") + if i + 1 < len(args): + return args[i + 1] + for a in args: + if a.startswith("pixellauncher://"): + return a + return None + + +def _profile_arg(): + args = sys.argv[1:] + if "--profile" in args: + i = args.index("--profile") + if i + 1 < len(args): + return args[i + 1] + return None + + +if __name__ == "__main__": + run(select_version=_select_version(), protocol_url=_protocol_url(), + select_profile=_profile_arg()) diff --git a/make_installer.py b/make_installer.py new file mode 100644 index 0000000..cdfa7ca --- /dev/null +++ b/make_installer.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +"""Сборка установщика PixelLauncher (Inno Setup 6). + +Что делает: + 1) runtime: embeddable Python (с python.org) + tkinter/Tcl из локальной + установки той же версии — лаунчеру нужен только stdlib; + 2) приложение: main.py + launcher/ + run.bat + README + requirements; + 3) иконка: assets/icon.png -> build/launcher.ico (PNG-in-ICO); + 4) компиляция: ISCC.exe installer.iss -> PixelLauncherSetup<версия>.exe + в папке проекта. + +Запуск: python make_installer.py +Нужны: Inno Setup 6 (ISCC.exe) и интернет (первый раз — embeddable Python). +""" +import os +import shutil +import struct +import subprocess +import sys +import urllib.request +import zipfile + +ROOT = os.path.dirname(os.path.abspath(__file__)) +BUILD = os.path.join(ROOT, "build") +STAGE = os.path.join(BUILD, "stage") +ISCC = r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + +PYVER = "%d.%d.%d" % sys.version_info[:3] +APP_VERSION = "2.1.0" # должен совпадать с LAUNCHER_VERSION в launcher/__init__.py +SETUP_NAME = "PixelLauncherSetup%s.exe" % APP_VERSION + + +def log(msg): + print(msg, flush=True) + + +def embeddable_zip(): + name = "python-%s-embed-amd64.zip" % PYVER + path = os.path.join(BUILD, name) + if not os.path.isfile(path): + url = "https://www.python.org/ftp/python/%s/%s" % (PYVER, name) + log("Скачиваю %s ..." % url) + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=300) as r, \ + open(path, "wb") as f: + while True: + chunk = r.read(1 << 20) + if not chunk: + break + f.write(chunk) + return path + + +def build_stage(): + if os.path.isdir(STAGE): + shutil.rmtree(STAGE, ignore_errors=True) + os.makedirs(STAGE) + + log("Распаковка embeddable Python %s ..." % PYVER) + with zipfile.ZipFile(embeddable_zip()) as z: + z.extractall(STAGE) + + # --- tkinter/Tcl: из локальной установки той же версии + base = sys.base_prefix + dlls = os.path.join(base, "DLLs") + copied = [] + for fname in os.listdir(dlls): + low = fname.lower() + if low in ("_tkinter.pyd", "zlib1.dll") or ( + low.endswith(".dll") and (low.startswith("tcl") + or low.startswith("tk"))): + shutil.copy2(os.path.join(dlls, fname), + os.path.join(STAGE, fname)) + copied.append(fname) + tcl_src = os.path.join(base, "tcl") + assert os.path.isdir(tcl_src), "нет папки tcl в " + base + shutil.copytree(tcl_src, os.path.join(STAGE, "tcl")) + # сам пакет tkinter вырезан из embeddable — копируем из установки + tk_src = os.path.join(base, "Lib", "tkinter") + assert os.path.isdir(tk_src), "нет папки tkinter в " + base + shutil.copytree(tk_src, os.path.join(STAGE, "tkinter"), + ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + copied.append("tkinter/") + log("tkinter/Tcl: " + ", ".join(copied) + " + tcl/") + + # ._pth: гарантируем точку в пути (иначе import launcher не найдёт) + pth = [f for f in os.listdir(STAGE) if f.endswith("._pth")][0] + pth_path = os.path.join(STAGE, pth) + lines = open(pth_path, encoding="utf-8").read().splitlines() + if "." not in lines: + lines.insert(1, ".") + with open(pth_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + # --- приложение + app_files = ["main.py", "run.bat", "README.md", "requirements.txt"] + for name in app_files: + src = os.path.join(ROOT, name) + if os.path.isfile(src): + shutil.copy2(src, os.path.join(STAGE, name)) + shutil.copytree( + os.path.join(ROOT, "launcher"), + os.path.join(STAGE, "launcher"), + ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + + # --- иконка PNG -> ICO (ICO с PNG-полезной нагрузкой) + png_path = os.path.join(ROOT, "assets", "icon.png") + if os.path.isfile(png_path): + ico = os.path.join(STAGE, "launcher.ico") + make_ico(png_path, ico) + shutil.copy2(ico, os.path.join(BUILD, "launcher.ico")) + log("иконка: launcher.ico") + + log("stage: %d файлов" % sum(len(f) for _r, _d, f in os.walk(STAGE))) + + +def make_ico(png_src, ico_dst): + png = open(png_src, "rb").read() + w, h = struct.unpack(">II", png[16:24]) + entry = struct.pack("nul 2>nul && (start "" pythonw main.py & exit /b 0) +where pyw >nul 2>nul && (start "" pyw -3 main.py & exit /b 0) +where py >nul 2>nul && (py -3 main.py & exit /b 0) +python main.py