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

+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