PixelLauncher 2.1.0 — стабильная сборка

Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров
(Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление
модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
This commit is contained in:
kosta2014p committed 2026-10-18 22:44:19 +07:00
commit ec6f13ddcc
33 files changed
+10445

No files matched your search

+27
View File
@@ -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
+215
View File
@@ -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` (апгрейд со старой версии мастер
не показывает).
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+276
View File
@@ -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)
+74
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
"""PixelLauncher — lightweight Minecraft launcher (offline mode)."""
LAUNCHER_NAME = "PixelLauncher"
LAUNCHER_VERSION = "2.1.0"
+153
View File
@@ -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
+90
View File
@@ -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
+225
View File
@@ -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
+199
View File
@@ -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
+314
View File
@@ -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
+483
View File
@@ -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
+175
View File
@@ -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
+198
View File
@@ -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
+99
View File
@@ -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
+111
View File
@@ -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}")
+176
View File
@@ -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
+198
View File
@@ -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
+145
View File
@@ -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
+305
View File
@@ -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()
+390
View File
@@ -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)
+173
View File
@@ -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
+130
View File
@@ -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
+132
View File
@@ -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
+252
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+265
View File
@@ -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)
+145
View File
@@ -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
+51
View File
@@ -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())
+159
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
# Здесь лаунчер распаковывает пользовательские плагины (магазин и ручной импорт).
# Файлы в этой папке не коммитятся — только этот маркер.
+3
View File
@@ -0,0 +1,3 @@
# Внешние зависимости не требуются.
# Достаточно Python 3.8+ (всё реализовано на стандартной библиотеке:
# tkinter, urllib, json, zipfile, concurrent.futures).
+7
View File
@@ -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