PixelLauncher 2.1.0 — стабильная сборка
Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров (Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
This commit is contained in:
commit
ec6f13ddcc
33 files changed
+10445
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user