- Подменю «Быстрые настройки» (☰): светлая тема, автозагрузка Windows, трей при запуске игры, авто-бэкап мира, масштаб интерфейса (100/125/150%), язык (RU/EN) — переключаются сразу и сохраняются, без открытия диалога «Настройки»; при сбое автозагрузки флажок возвращается обратно. - Версия 2.1.1: бамп по всем файлам (лаунчер, installer.iss, UA, манифест).
315 lines
12 KiB
Python
315 lines
12 KiB
Python
"""Минимальные 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
|