zapret-kvn/xray_fluent/app_updater.py
2026-03-26 16:20:53 +03:00

547 lines
22 KiB
Python
Raw Permalink 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.

"""Self-update: check GitHub releases, download, extract, restart."""
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import urllib.request
import zipfile # kept for legacy .zip support
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from urllib.request import Request
from .http_utils import build_opener, urlopen
from PyQt6.QtCore import QThread, pyqtSignal
from .constants import APP_VERSION, BASE_DIR
GITHUB_REPO = "youtubediscord/zapret-kvn"
GITHUB_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
USER_AGENT = f"ZapretKVN/{APP_VERSION}"
def _powershell_literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _write_utf8_bom_text(path: Path, text: str) -> None:
path.write_bytes(b"\xef\xbb\xbf" + text.encode("utf-8"))
def _resolve_extracted_app_dir(root: Path, exe_name: str) -> Path:
if (root / exe_name).is_file():
return root
child_dirs = [path for path in root.iterdir() if path.is_dir()]
if len(child_dirs) == 1 and (child_dirs[0] / exe_name).is_file():
return child_dirs[0]
for path in child_dirs:
if (path / exe_name).is_file():
return path
return root
@dataclass(slots=True)
class AppUpdate:
version: str
tag: str
download_url: str
size: int
notes: str
digest_sha256: str = ""
_SEMVER_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?")
def _parse_semver(version: str) -> tuple[int, int, int, list[str]] | None:
match = _SEMVER_RE.search(version.strip().lstrip("v"))
if not match:
return None
major, minor, patch, suffix = match.groups()
prerelease = suffix.split(".") if suffix else []
return int(major), int(minor), int(patch), prerelease
def _compare_prerelease(left: list[str], right: list[str]) -> int:
if not left and not right:
return 0
if not left:
return 1
if not right:
return -1
for left_part, right_part in zip(left, right):
if left_part == right_part:
continue
left_is_num = left_part.isdigit()
right_is_num = right_part.isdigit()
if left_is_num and right_is_num:
left_num = int(left_part)
right_num = int(right_part)
if left_num != right_num:
return 1 if left_num > right_num else -1
continue
if left_is_num != right_is_num:
return -1 if left_is_num else 1
return 1 if left_part > right_part else -1
if len(left) == len(right):
return 0
return 1 if len(left) > len(right) else -1
def _is_newer_version(latest: str, current: str) -> bool:
latest_parts = _parse_semver(latest)
current_parts = _parse_semver(current)
if latest_parts is None or current_parts is None:
return latest.strip().lstrip("v") != current.strip().lstrip("v")
latest_core = latest_parts[:3]
current_core = current_parts[:3]
if latest_core != current_core:
return latest_core > current_core
return _compare_prerelease(latest_parts[3], current_parts[3]) > 0
def _extract_digest(value: str) -> str:
text = value.strip().lower()
if text.startswith("sha256:"):
text = text.split(":", 1)[1].strip()
parts = "".join(ch for ch in text if ch in "0123456789abcdef")
return parts if len(parts) == 64 else ""
def _sha256_file(file_path: Path) -> str:
digest = hashlib.sha256()
with open(file_path, "rb") as file:
while True:
chunk = file.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def _fetch_text(url: str) -> str:
request = Request(url, headers={"User-Agent": USER_AGENT})
with urlopen(request, timeout=15) as response:
return response.read().decode("utf-8", errors="replace")
class UpdateChecker(QThread):
"""Check GitHub for a newer release."""
result = pyqtSignal(object) # AppUpdate | None
error = pyqtSignal(str)
def run(self) -> None:
try:
req = Request(GITHUB_API, headers={"User-Agent": USER_AGENT})
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
tag = data.get("tag_name", "")
if not _is_newer_version(tag, APP_VERSION):
self.result.emit(None)
return
asset = None
for a in data.get("assets", []):
name = a.get("name", "").lower()
if name.endswith(".zip") and "windows" in name and "x64" in name:
asset = a
break
if not asset:
self.error.emit(f"Релиз {tag} найден, но отсутствует Windows zip-архив")
return
digest = _extract_digest(str(asset.get("digest") or ""))
if not digest:
asset_name = str(asset.get("name") or "")
sidecar = None
for suffix in (".sha256", ".dgst"):
expected = f"{asset_name}{suffix}".lower()
sidecar = next(
(
candidate for candidate in data.get("assets", [])
if str(candidate.get("name") or "").lower() == expected
),
None,
)
if sidecar:
break
if sidecar:
digest = _extract_digest(
_fetch_text(str(sidecar.get("browser_download_url") or ""))
)
if not digest:
self.error.emit(f"Релиз {tag} найден, но архив не содержит SHA-256")
return
self.result.emit(AppUpdate(
version=tag.lstrip("v"),
tag=tag,
download_url=asset["browser_download_url"],
size=asset.get("size", 0),
notes=data.get("body", ""),
digest_sha256=digest,
))
except Exception as exc:
self.error.emit(str(exc))
return
_log = logging.getLogger(__name__)
_DOWNLOAD_TIMEOUT = 30 # seconds — per socket operation (connect + each read)
_NUM_SEGMENTS = 4 # parallel download segments
_CHUNK_SIZE = 1024 * 1024 # 1 MB
class UpdateDownloader(QThread):
"""Download and extract update, then launch restart script."""
progress = pyqtSignal(int) # percent 0-100
status = pyqtSignal(str) # human-readable status message
finished_ok = pyqtSignal()
error = pyqtSignal(str)
def __init__(
self,
update: AppUpdate,
proxy_url: str | None = None,
restart_in_tray: bool = False,
parent=None,
):
super().__init__(parent)
self._update = update
self._proxy_url = proxy_url
self._restart_in_tray = restart_in_tray
# ── download helpers ────────────────────────────────────────
def _build_opener(self, proxy_url: str | None) -> urllib.request.OpenerDirector:
if proxy_url:
handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
return build_opener(handler)
return build_opener()
def _supports_range(self, url: str, opener: urllib.request.OpenerDirector) -> tuple[bool, int]:
"""HEAD request to check Range support and get Content-Length."""
req = Request(url, method="HEAD", headers={"User-Agent": USER_AGENT})
with opener.open(req, timeout=_DOWNLOAD_TIMEOUT) as resp:
accepts = resp.headers.get("Accept-Ranges", "").lower()
length = int(resp.headers.get("Content-Length", 0))
return accepts == "bytes" and length > 0, length
def _download_segment(
self,
url: str,
proxy_url: str | None,
start: int,
end: int,
seg_path: Path,
seg_index: int,
lock: threading.Lock,
progress_arr: list[int],
total: int,
) -> None:
"""Download one segment with Range header."""
opener = self._build_opener(proxy_url)
expected_length = end - start + 1
req = Request(url, headers={
"User-Agent": USER_AGENT,
"Range": f"bytes={start}-{end}",
})
with opener.open(req, timeout=_DOWNLOAD_TIMEOUT) as resp:
status_code = getattr(resp, "status", None)
content_range = resp.headers.get("Content-Range", "")
if status_code != 206 or not content_range.startswith(f"bytes {start}-{end}/"):
raise RuntimeError("Сервер некорректно ответил на Range-запрос")
downloaded = 0
with open(seg_path, "wb") as f:
while True:
chunk = resp.read(_CHUNK_SIZE)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
with lock:
progress_arr[seg_index] += len(chunk)
done = sum(progress_arr)
self.progress.emit(int(done * 100 / total))
if downloaded != expected_length:
raise RuntimeError("Сервер вернул неполный фрагмент архива")
def _download_single(self, url: str, opener: urllib.request.OpenerDirector, zip_path: Path) -> None:
"""Single-connection fallback download."""
req = Request(url, headers={"User-Agent": USER_AGENT})
with opener.open(req, timeout=_DOWNLOAD_TIMEOUT) as resp:
total = int(resp.headers.get("Content-Length", 0))
downloaded = 0
with open(zip_path, "wb") as f:
while True:
chunk = resp.read(_CHUNK_SIZE)
if not chunk:
if downloaded == 0:
raise TimeoutError("Сервер не отдаёт данные")
break
f.write(chunk)
downloaded += len(chunk)
if total > 0:
self.progress.emit(int(downloaded * 100 / total))
def _download(self, zip_path: Path, proxy_url: str | None) -> None:
"""Download update zip with multi-segment acceleration.
Tries parallel Range-based download first; falls back to single
connection if the server doesn't support Range requests.
"""
url = self._update.download_url
opener = self._build_opener(proxy_url)
# Check if server supports Range requests
try:
supports_range, total = self._supports_range(url, opener)
except Exception:
supports_range, total = False, 0
if not supports_range or total == 0 or total < _NUM_SEGMENTS * _CHUNK_SIZE:
_log.info("Server does not support Range or file too small — single download")
self._download_single(url, opener, zip_path)
return
# Split into segments
seg_size = total // _NUM_SEGMENTS
segments: list[tuple[int, int]] = []
for i in range(_NUM_SEGMENTS):
start = i * seg_size
end = total - 1 if i == _NUM_SEGMENTS - 1 else (i + 1) * seg_size - 1
segments.append((start, end))
# Prepare temp segment files
seg_dir = zip_path.parent / "_segments"
seg_dir.mkdir(exist_ok=True)
seg_paths = [seg_dir / f"seg_{i}" for i in range(_NUM_SEGMENTS)]
lock = threading.Lock()
progress_arr = [0] * _NUM_SEGMENTS
# Download segments in parallel
try:
with ThreadPoolExecutor(max_workers=_NUM_SEGMENTS) as pool:
futures = []
for i, (start, end) in enumerate(segments):
fut = pool.submit(
self._download_segment,
url, proxy_url, start, end,
seg_paths[i], i, lock, progress_arr, total,
)
futures.append(fut)
# Re-raise any segment exception
for fut in futures:
fut.result()
# Concatenate segments into final file
with open(zip_path, "wb") as out:
for sp in seg_paths:
with open(sp, "rb") as seg_f:
shutil.copyfileobj(seg_f, out)
except Exception as exc:
_log.warning("Segmented download failed, falling back to single download: %s", exc)
if zip_path.exists():
zip_path.unlink()
self.progress.emit(0)
self._download_single(url, opener, zip_path)
finally:
# Clean up segment temp files
shutil.rmtree(seg_dir, ignore_errors=True)
# ── main thread entry ───────────────────────────────────────
def run(self) -> None:
tmp_dir: Path | None = None
try:
tmp_dir = Path(tempfile.mkdtemp(prefix="zapretkvn_update_"))
zip_path = tmp_dir / "update.zip"
downloaded_ok = False
# Attempt 1: through proxy (if available)
if self._proxy_url:
self.status.emit("Загрузка через прокси...")
try:
self._download(zip_path, self._proxy_url)
downloaded_ok = True
except Exception as exc:
_log.warning("Proxy download failed: %s", exc)
self.status.emit(
"Прокси-сервер недоступен, пробую напрямую..."
)
self.progress.emit(0)
# clean partial file
if zip_path.exists():
zip_path.unlink()
# Attempt 2: direct (no proxy)
if not downloaded_ok:
self.status.emit("Загрузка напрямую...")
try:
self._download(zip_path, None)
downloaded_ok = True
except Exception as exc:
_log.warning("Direct download failed: %s", exc)
if not downloaded_ok:
msg = (
"Не удалось скачать обновление.\n"
"Переключитесь на рабочий сервер и попробуйте снова."
)
if self._proxy_url:
msg = (
"Не удалось скачать обновление ни через прокси, ни напрямую.\n"
"Переключитесь на рабочий сервер и попробуйте снова."
)
self.error.emit(msg)
# cleanup
shutil.rmtree(tmp_dir, ignore_errors=True)
return
self.status.emit("Проверка архива...")
expected_hash = _extract_digest(self._update.digest_sha256)
if not expected_hash:
self.error.emit("У релизного архива отсутствует SHA-256")
shutil.rmtree(tmp_dir, ignore_errors=True)
return
real_hash = _sha256_file(zip_path)
if real_hash.lower() != expected_hash.lower():
self.error.emit("Контрольная сумма архива не совпадает")
shutil.rmtree(tmp_dir, ignore_errors=True)
return
self.progress.emit(100)
self.status.emit("Распаковка...")
# Extract
extract_dir = tmp_dir / "extracted"
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(extract_dir)
exe_name = "ZapretKVN.exe"
source_dir = _resolve_extracted_app_dir(extract_dir, exe_name)
if not (source_dir / exe_name).is_file():
self.error.emit("Архив обновления не содержит ZapretKVN.exe")
shutil.rmtree(tmp_dir, ignore_errors=True)
return
# Write restart script
current_pid = os.getpid()
app_dir = BASE_DIR
script = tmp_dir / "_update.ps1"
script_text = "\r\n".join([
"$ErrorActionPreference = 'Stop'",
f"$pidToWait = {current_pid}",
f"$sourceDir = {_powershell_literal(str(source_dir))}",
f"$appDir = {_powershell_literal(str(app_dir))}",
f"$exePath = {_powershell_literal(str(app_dir / exe_name))}",
f"$tempDir = {_powershell_literal(str(tmp_dir))}",
"$logDir = Join-Path (Join-Path $appDir 'data') 'logs'",
"$runtimeDir = Join-Path (Join-Path $appDir 'data') 'runtime'",
"$errorLog = Join-Path $logDir 'update_error.log'",
"$preserveNames = @('data')",
"$backupDir = Join-Path $runtimeDir 'update_backup'",
"$backupReplaceDir = Join-Path $backupDir 'replace'",
"$backupStaleDir = Join-Path $backupDir 'stale'",
"Remove-Item -LiteralPath $backupDir -Recurse -Force -ErrorAction SilentlyContinue",
"New-Item -ItemType Directory -Path $runtimeDir -Force | Out-Null",
"New-Item -ItemType Directory -Path $backupReplaceDir -Force | Out-Null",
"New-Item -ItemType Directory -Path $backupStaleDir -Force | Out-Null",
"for ($i = 0; $i -lt 120; $i++) {",
" if (-not (Get-Process -Id $pidToWait -ErrorAction SilentlyContinue)) { break }",
" Start-Sleep -Milliseconds 500",
"}",
"$proc = Get-Process -Id $pidToWait -ErrorAction SilentlyContinue",
"if ($proc) { Stop-Process -Id $pidToWait -Force }",
"$sourceItems = @(Get-ChildItem -LiteralPath $sourceDir -Force | Where-Object { $preserveNames -notcontains $_.Name })",
"$sourceNames = @($sourceItems | ForEach-Object { $_.Name })",
"try {",
" Get-ChildItem -LiteralPath $appDir -Force | Where-Object { $preserveNames -notcontains $_.Name } | ForEach-Object {",
" $backupTarget = if ($sourceNames -contains $_.Name) { $backupReplaceDir } else { $backupStaleDir }",
" Move-Item -LiteralPath $_.FullName -Destination $backupTarget -Force",
" }",
" foreach ($item in $sourceItems) {",
" Copy-Item -LiteralPath $item.FullName -Destination $appDir -Recurse -Force",
" }",
(
" $started = Start-Process -FilePath $exePath -ArgumentList '--tray' -WorkingDirectory $appDir -PassThru -ErrorAction Stop"
if self._restart_in_tray
else " $started = Start-Process -FilePath $exePath -WorkingDirectory $appDir -PassThru -ErrorAction Stop"
),
" Start-Sleep -Seconds 5",
" if ($started.HasExited) {",
" throw ('Updated application exited immediately with code ' + $started.ExitCode)",
" }",
" Remove-Item -LiteralPath $backupDir -Recurse -Force -ErrorAction SilentlyContinue",
"}",
"catch {",
" Get-ChildItem -LiteralPath $appDir -Force -ErrorAction SilentlyContinue | Where-Object { $preserveNames -notcontains $_.Name } | ForEach-Object {",
" Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue",
" }",
" Get-ChildItem -LiteralPath $backupReplaceDir -Force -ErrorAction SilentlyContinue | ForEach-Object {",
" Move-Item -LiteralPath $_.FullName -Destination $appDir -Force",
" }",
" Get-ChildItem -LiteralPath $backupStaleDir -Force -ErrorAction SilentlyContinue | ForEach-Object {",
" Move-Item -LiteralPath $_.FullName -Destination $appDir -Force",
" }",
(
" if (Test-Path -LiteralPath $exePath) { Start-Process -FilePath $exePath -ArgumentList '--tray' -WorkingDirectory $appDir -ErrorAction SilentlyContinue | Out-Null }"
if self._restart_in_tray
else " if (Test-Path -LiteralPath $exePath) { Start-Process -FilePath $exePath -WorkingDirectory $appDir -ErrorAction SilentlyContinue | Out-Null }"
),
" New-Item -ItemType Directory -Path $logDir -Force | Out-Null",
" ($_ | Out-String) | Set-Content -LiteralPath $errorLog -Encoding UTF8",
" Remove-Item -LiteralPath $backupDir -Recurse -Force -ErrorAction SilentlyContinue",
" throw",
"}",
"Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue",
"",
])
_write_utf8_bom_text(script, script_text)
# Launch script and exit
subprocess.Popen(
[
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-WindowStyle",
"Hidden",
"-File",
str(script),
],
creationflags=0x08000000,
close_fds=True,
)
self.finished_ok.emit()
except Exception as exc:
if tmp_dir is not None:
shutil.rmtree(tmp_dir, ignore_errors=True)
self.error.emit(str(exc))