Files
PixelLauncher/launcher/http.py
T
kosta2014p 80dc1826a1 PixelLauncher 2.1.1 — быстрые настройки в главном меню
- Подменю «Быстрые настройки» (☰): светлая тема, автозагрузка Windows,
  трей при запуске игры, авто-бэкап мира, масштаб интерфейса (100/125/150%),
  язык (RU/EN) — переключаются сразу и сохраняются, без открытия диалога
  «Настройки»; при сбое автозагрузки флажок возвращается обратно.
- Версия 2.1.1: бамп по всем файлам (лаунчер, installer.iss, UA, манифест).
2026-10-19 19:26:18 +07:00

315 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Минимальные 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.1 (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