PixelLauncher 2.1.0 — стабильная сборка
Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров (Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
This commit is contained in:
commit
ec6f13ddcc
33 files changed
+10445
No files matched your search
+27
@@ -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
|
||||
@@ -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/<url>` скачивает и импортирует
|
||||
модпак. Ключи 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` (апгрейд со старой версии мастер
|
||||
не показывает).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
"""PixelLauncher — lightweight Minecraft launcher (offline mode)."""
|
||||
|
||||
LAUNCHER_NAME = "PixelLauncher"
|
||||
LAUNCHER_VERSION = "2.1.0"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"<version>\s*([^<]+?)\s*</version>", 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Скачивание и установка Java с Eclipse Adoptium, если она не найдена.
|
||||
|
||||
Ставит JRE в `.launcher/runtime/java-<major>` и возвращает путь к 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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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("<Return>", 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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Протокол pixellauncher:// — ссылки с сайта открывает лаунчер.
|
||||
|
||||
Схемы:
|
||||
pixellauncher://plugin/<файл.py> — установить плагин из магазина
|
||||
pixellauncher://mrpack/<url> — скачать и импортировать модпак
|
||||
|
||||
Реестр 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+5269
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PixelLauncher — лёгкий Minecraft-лаунчер в духе LiteLauncher.
|
||||
|
||||
Аргументы:
|
||||
--version <id> — сразу выбрать указанную версию (для ярлыков рабочего стола)
|
||||
--profile <имя> — применить профиль запуска (ярлыки профилей рабочего стола)
|
||||
--protocol-url <pixellauncher://…> — ссылка из браузера/с сайта;
|
||||
допустимо и просто первым аргументом: 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())
|
||||
@@ -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("<BBBBHHII", w if w < 256 else 0,
|
||||
h if h < 256 else 0, 0, 0, 1, 32,
|
||||
len(png), 6 + 16)
|
||||
with open(ico_dst, "wb") as f:
|
||||
f.write(struct.pack("<HHH", 0, 1, 1))
|
||||
f.write(entry)
|
||||
f.write(png)
|
||||
|
||||
|
||||
def compile_setup():
|
||||
assert os.path.isfile(ISCC), "ISCC.exe не найден: " + ISCC
|
||||
iss = os.path.join(ROOT, "installer.iss")
|
||||
log("Компиляция %s ..." % os.path.basename(iss))
|
||||
r = subprocess.run([ISCC, "/Q", iss], capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace")
|
||||
out = (r.stdout or "") + (r.stderr or "")
|
||||
if r.returncode != 0:
|
||||
print(out)
|
||||
raise SystemExit("ISCC failed: %d" % r.returncode)
|
||||
exe = os.path.join(ROOT, SETUP_NAME)
|
||||
assert os.path.isfile(exe), "нет результата: " + exe
|
||||
log("ГОТОВО: %s (%.1f МБ)" % (exe, os.path.getsize(exe) / 1e6))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not os.path.isfile(os.path.join(ROOT, "assets", "icon.png")):
|
||||
print("предупреждение: assets/icon.png нет — иконка не будет")
|
||||
build_stage()
|
||||
# smoke: стейдж самодостаточен (включая реальный Tk + SSL)
|
||||
smoke = subprocess.run(
|
||||
[os.path.join(STAGE, "python.exe"), "-c",
|
||||
"import tkinter,ssl,urllib.request,json,zipfile;"
|
||||
"r=tkinter.Tk(); r.withdraw(); r.destroy();"
|
||||
"import launcher.config, launcher.http, launcher.manifest;"
|
||||
"print('stage ok')"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
cwd=STAGE)
|
||||
print((smoke.stdout or "").strip(), (smoke.stderr or "").strip()[-800:])
|
||||
assert smoke.returncode == 0, "smoke стейджа упал"
|
||||
compile_setup()
|
||||
@@ -0,0 +1,2 @@
|
||||
# Здесь лаунчер распаковывает пользовательские плагины (магазин и ручной импорт).
|
||||
# Файлы в этой папке не коммитятся — только этот маркер.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Внешние зависимости не требуются.
|
||||
# Достаточно Python 3.8+ (всё реализовано на стандартной библиотеке:
|
||||
# tkinter, urllib, json, zipfile, concurrent.futures).
|
||||
@@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
if exist "%~dp0pythonw.exe" (start "" "%~dp0pythonw.exe" main.py & exit /b 0)
|
||||
where pythonw >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
|
||||
Reference in new issue
Block a user