Files
Launcher/core_lib/common/common.py
T
wei 6ab807356d Add account manager and the account page is now fully functional.
Fix "Missing javaVersion" error when launching legacy Minecraft.

Add settings page. (But is not fully implemented yet)
2026-08-03 00:16:12 +08:00

329 lines
10 KiB
Python

from __future__ import annotations
import base64
import hashlib
import logging
import platform
import zipfile
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import List
import requests
from .exception import HashMismatchException, DownloadException
DEFAULT_HASH_CHUNK_SIZE = 4096
DEFAULT_DOWNLOAD_CHUNK_SIZE = 4096
THREADED_DOWNLOAD_MAX_WORKERS = 10
logger = logging.getLogger("Launcher.CoreLib")
class FileObject:
def __init__(self, path: Path, url=None, sha1=None, sha256=None, sha512=None, md5=None, size=None):
self.__path = path
self.expected_sha1 = sha1
self.expected_sha256 = sha256
self.expected_sha512 = sha512
self.expected_md5 = md5
self.expected_size = size
self.__sha1 = None
self.__sha256 = None
self.__sha512 = None
self.__md5 = None
self.url = url
def __eq__(self, other):
"""
THIS OPERATION ONLY COMPARE AND FILE SIZE! Use
:param other:
:return:
"""
if self.path == other.path:
return True
if self.size == other.size:
return True
return False
def is_same(self, file: FileObject, hash_name="sha256") -> bool:
if hash_name == "sha1":
return self.sha1 == file.sha1
if hash_name == "md5":
return self.md5 == file.md5
if hash_name != "sha256":
logger.warning("Unsupported hash_name {}. Ignored and use sha256 instead.".format(hash_name))
return self.sha256 == file.sha256
@property
def sha1(self):
if self.__sha1 is None:
self.__sha1 = self._calculate_hash("sha1")
return self.__sha1
@property
def sha256(self):
if self.__sha256 is None:
self.__sha256 = self._calculate_hash("sha256")
return self.__sha256
@property
def sha512(self):
if self.__sha512 is None:
self.__sha512 = self._calculate_hash("sha512")
return self.__sha512
@property
def md5(self):
if self.__md5 is None:
self.__md5 = self._calculate_hash("md5")
return self.__md5
def verify_sha1(self, expected_hash=None):
return self.sha1 == expected_hash if expected_hash else self.expected_sha1 == self.sha1
def verify_sha256(self, expected_hash=None):
return self.sha256 == expected_hash if expected_hash else self.expected_sha256 == self.sha256
def verify_sha512(self, expected_hash=None):
return self.sha512 == expected_hash if expected_hash else self.expected_sha512 == self.sha512
def verify_md5(self, expected_hash=None):
return self.sha1 == expected_hash if expected_hash else self.expected_sha1 == self.sha1
def verify(self, algorithm, expected_hash=None):
if algorithm == "sha1":
return self.verify_sha1(expected_hash if expected_hash else self.expected_sha1)
if algorithm == "sha256":
return self.verify_sha256(expected_hash if expected_hash else self.expected_sha256)
if algorithm == "sha512":
return self.verify_sha512(expected_hash if expected_hash else self.expected_sha512)
if algorithm == "md5":
return self.verify_md5(expected_hash if expected_hash else self.expected_md5)
if algorithm == "size":
return self.verify_size(expected_hash if expected_hash else self.expected_size)
raise Exception("Unsupported algorithm: {}".format(algorithm))
def verify_size(self, expected_size):
return self.size == expected_size if expected_size else self.expected_size == self.size
def _calculate_hash(self, algorithm):
h = hashlib.new(algorithm)
with self.__path.open("rb") as f:
for chunk in iter(lambda: f.read(DEFAULT_HASH_CHUNK_SIZE), b""):
h.update(chunk)
return h.hexdigest()
@property
def path(self):
return self.__path
@property
def posix_path(self):
return self.__path.absolute().as_posix()
@property
def size(self):
return self.__path.stat().st_size
@property
def name(self):
return self.__path.name
@property
def extension(self):
return self.__path.suffix
def __hash__(self):
return hash(self.__path)
@property
def exists(self):
return self.__path.exists()
def download_file(file: FileObject, overwrite=True, progress_callback: Callable[int]=None) -> FileObject | Exception:
"""
Download file from url
:param file: File (Objected)
:param overwrite: Overwrite existing file
:param progress_callback: Callback function called with progress information
:return:
"""
file.path.parent.mkdir(parents=True, exist_ok=True)
if file.exists and not overwrite:
raise FileExistsError("File {} already exists. Use overwrite=True to overwrite.".format(file))
if file.url is None:
raise ValueError("Target file {} URL cannot be None.".format(file))
logger.debug("Downloading file {} to {}".format(file.url, file))
try:
with requests.get(file.url, stream=True) as r:
r.raise_for_status()
current_chunk = 0
total_bytes = r.headers.get("content-length", None)
logger.debug("URL: {}".format(file.url))
logger.debug("Response code: {}".format(r.status_code))
with file.path.open("wb") as f:
for chunk in r.iter_content(chunk_size=DEFAULT_DOWNLOAD_CHUNK_SIZE):
if not chunk:
break
f.write(chunk)
current_chunk += 1
if callable(progress_callback) and total_bytes is not None:
current_progress = ((current_chunk * DEFAULT_DOWNLOAD_CHUNK_SIZE) / int(total_bytes)) * 100
progress_callback(file.name, current_progress)
except Exception as e:
raise DownloadException(
"An error occurred while downloading file {}. {}".format(file.url, e)
)
if file.expected_sha256 and not file.verify_sha256():
raise HashMismatchException("sha256", file.sha256, file.expected_sha256, file.posix_path)
elif file.expected_sha1 and not file.verify_sha1():
raise HashMismatchException("sha1", file.sha1, file.expected_sha1, file.posix_path)
elif file.expected_md5 and not file.verify_md5():
raise HashMismatchException("md5", file.md5, file.expected_md5, file.posix_path)
if any([file.expected_sha1, file.expected_md5, file.expected_sha256]):
logger.debug("File hash is match as the expected hash.")
else:
logger.warning("Hash is not checked for file: {}".format(file.posix_path))
return file
def download_multiple_files(files: List[FileObject], download_callback: Callable[[str], None]=None,
max_workers=THREADED_DOWNLOAD_MAX_WORKERS, progress_callback: Callable[str]=None) \
-> tuple[list[FileObject], list[FileObject]]:
"""
Download multiple files
:param download_callback:
:param files:
:param max_workers:
:param progress_callback:
:return:
fails: List of file objects that download failed.
successes: List of file objects that download succeeded.
"""
fails = []
successes = []
files_count = 0
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_file = {executor.submit(download_file, file): file for file in files}
for future in as_completed(future_to_file):
file = future_to_file[future]
try:
files_count += 1
future.result()
successes.append(file)
if callable(download_callback):
download_callback(file.posix_path)
if callable(progress_callback):
progress_callback(f"{len(files)} Files", files_count / len(files) * 100)
except Exception as exc:
logger.error("Unable to download file {}: {}".format(file.posix_path, exc))
fails.append(file)
return fails, successes
def unzip(filepath: Path, destination: Path, exclude: list[str] | None = None, flatten: bool=False):
exclude = exclude or []
with zipfile.ZipFile(filepath.as_posix(), "r") as archive:
for member in archive.namelist():
# Ignore exclude file.
if any(member.startswith(pattern) for pattern in exclude):
continue
if flatten:
member_path = Path(member)
if member.endswith("/") or not member_path.name:
continue
if any(member.startswith(pattern) for pattern in exclude):
continue
target = destination / member_path.name
with archive.open(member) as source:
with target.open("wb") as output:
output.write(source.read())
else:
archive.extract(member, path=destination)
def get_platform_info():
"""
Get information about the current platform.
IMPORTANT: All return values are lower-case
:return:
platform_name: str, architecture: str, os_version: str
"""
platform_name = platform.system().lower()
arch = platform.uname().machine.lower()
os_version = platform.version()
if platform_name == "darwin":
os_version = platform.mac_ver()[0]
elif platform_name == "linux":
os_version = platform.release()
return platform_name, arch, os_version
def is_valid_zipfile(path: Path) -> bool:
try:
return zipfile.is_zipfile(path)
except OSError:
return False
def bytes_to_data_url(image_data: bytes, mime_type: str = "image/png",) -> str:
"""
Convert raw image bytes to a Base64 Data URL.
:param image_data:
:param mime_type:
:return:
data_url: str
"""
if not isinstance(image_data, bytes):
raise TypeError("image_data must be bytes")
if not image_data:
raise ValueError("image_data cannot be empty")
if not isinstance(mime_type, str) or not mime_type.startswith("image/"):
raise ValueError("mime_type must be a valid image MIME type")
encoded = base64.b64encode(image_data).decode("ascii")
return f"data:{mime_type};base64,{encoded}"