Files
Jackify/jackify/backend/handlers/native_component_downloader.py
T

116 lines
4.8 KiB
Python

"""Download helpers for the native Wine component installer.
Multi-source fetching with integrity checking, split out of
native_component_installer.py to keep that module within the file size limit.
"""
import hashlib
import logging
import time
import urllib.request
from pathlib import Path
from typing import Sequence, Union
logger = logging.getLogger(__name__)
class ComponentDownloadMixin:
"""Fetches component payloads. Expects the host class to provide `logger`,
`_emit_status()` and, where relevant, `_current_component`."""
def _download_file(self, url: Union[str, Sequence[str]], dest: Path,
sha256: Union[str, Sequence[str]] = "") -> bool:
urls = [url] if isinstance(url, str) else list(url)
if dest.is_file():
if not sha256:
return True
if self._verify_sha256(dest, sha256):
return True
self.logger.warning("SHA256 mismatch on cached %s, re-downloading", dest.name)
dest.unlink()
for index, candidate in enumerate(urls):
if not self._download_from_url(candidate, dest):
continue
if sha256 and not self._verify_sha256(dest, sha256):
self.logger.error("SHA256 mismatch on %s from %s", dest.name, candidate)
dest.unlink()
continue
return True
self.logger.error("All %d source(s) failed for %s", len(urls), dest.name)
return False
def _download_from_url(self, url: str, dest: Path) -> bool:
"""Download to a .part file and rename only once the transfer is verifiably
complete, so an interrupted download can never be left behind and trusted as a
cached copy on the next run."""
component = getattr(self, '_current_component', dest.stem)
self.logger.info("Downloading %s ...", dest.name)
self._emit_status(f"Downloading {dest.name}...")
partial = dest.with_name(dest.name + '.part')
try:
req = urllib.request.Request(url, headers={'User-Agent': 'Jackify/1.0'})
with urllib.request.urlopen(req, timeout=120) as resp:
total = int(resp.headers.get('Content-Length', 0) or 0)
downloaded = 0
start = time.monotonic()
last_emit = start
chunk_size = 65536
with open(partial, 'wb') as f:
while True:
chunk = resp.read(chunk_size)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
now = time.monotonic()
if total > 0 and now - last_emit >= 0.5:
pct = downloaded / total * 100.0
elapsed = now - start
speed = downloaded / elapsed / 1048576.0 if elapsed > 0.05 else 0.0
self._emit_status(f"[NATIVE_DL] {component} {pct:.1f} {speed:.1f}")
last_emit = now
if downloaded == 0:
self.logger.error("Download from %s produced an empty file", url)
partial.unlink(missing_ok=True)
return False
if total > 0 and downloaded != total:
self.logger.error(
"Truncated download from %s: got %d bytes, expected %d",
url, downloaded, total,
)
partial.unlink(missing_ok=True)
return False
partial.replace(dest)
return True
except Exception as exc:
self.logger.error("Download failed for %s: %s", url, exc)
partial.unlink(missing_ok=True)
return False
def _verify_sha256(self, path: Path, expected: Union[str, Sequence[str]]) -> bool:
accepted = {expected.lower()} if isinstance(expected, str) else {e.lower() for e in expected}
h = hashlib.sha256()
try:
with open(path, 'rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
h.update(chunk)
return h.hexdigest().lower() in accepted
except Exception:
return False
def _discard_cached_installer(self, path: Path) -> None:
"""Drop a cached installer that Wine refused to run. The redist URLs are not
version-pinned, so a corrupt cached copy is otherwise re-used on every retry and
no amount of re-running configuration would clear it."""
try:
path.unlink(missing_ok=True)
self.logger.info("Discarded cached installer %s so the next attempt re-downloads", path.name)
except Exception as exc:
self.logger.debug("Could not discard cached installer %s: %s", path.name, exc)