Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- import sys
- import os
- import json
- import requests
- import hashlib
- import subprocess
- import zipfile
- import shutil
- import glob
- import re
- from PyQt6.QtWidgets import (
- QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
- QPushButton, QListWidget, QComboBox, QLineEdit, QProgressBar,
- QLabel, QMessageBox, QMenuBar, QDialog, QInputDialog,
- QFileDialog, QCheckBox, QTextEdit, QScrollArea, QSizePolicy,
- QListWidgetItem # Dodano brakujący import
- )
- from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer
- from PyQt6.QtGui import QIcon, QPixmap
- import logging
- from datetime import datetime, timedelta # Import timedelta
- from collections import deque
- import time
- from pathlib import Path
- import signal # Potrzebne do obsługi Ctrl+C
- from packaging import version
- logging.basicConfig(
- filename="launcher.log",
- level=logging.INFO,
- format="%(asctime)s - %(levelname)s - %(message)s"
- )
- CONFIG_DIR = Path.cwd() / "minecraft_launcher"
- SETTINGS_FILE = CONFIG_DIR / "settings.json"
- ASSETS_DIR = CONFIG_DIR / "assets"
- LIBRARIES_DIR = CONFIG_DIR / "libraries"
- INSTANCES_DIR = CONFIG_DIR / "instances"
- JAVA_DIR = CONFIG_DIR / "java"
- MOD_ICONS_DIR = CONFIG_DIR / "mod_icons"
- CURSEFORGE_API_KEY = "$2a$10$dxb5k5YbdGcnXYwM4U7CF.VWOtmsUP3xt3fDssBnjyPwCpEFpJgs."
- DEFAULT_SETTINGS = {
- "theme": "Light",
- "java_path": "",
- "ram": "4G",
- "jvm_args": "-XX:+UnlockExperimentalVMOptions",
- "fullscreen": False,
- "resolution": "1280x720",
- "default_account": "Player"
- }
- STYLESHEET = """
- QDialog, QMainWindow {
- background-color: #f0f0f0;
- font-family: Arial;
- }
- QLabel {
- font-size: 14px;
- margin: 5px 0;
- }
- QProgressBar {
- border: 1px solid #ccc;
- border-radius: 5px;
- text-align: center;
- height: 20px;
- background-color: #e0e0e0;
- }
- QProgressBar::chunk {
- background-color: #4CAF50;
- border-radius: 3px;
- }
- QPushButton {
- background-color: #4CAF50;
- color: white;
- border: none;
- padding: 8px;
- border-radius: 5px;
- }
- QPushButton:hover {
- background-color: #45a049;
- }
- QPushButton:disabled {
- background-color: #cccccc;
- }
- QPushButton[deleteButton="true"] {
- background-color: #f44336;
- }
- QPushButton[deleteButton="true"]:hover {
- background-color: #d32f2f;
- }
- QPushButton[deleteButton="true"]:disabled {
- background-color: #cccccc;
- }
- QListWidget {
- border: 1px solid #ccc;
- border-radius: 5px;
- padding: 5px;
- background-color: white;
- alternate-background-color: #f5f5f5;
- }
- QScrollArea {
- border: none;
- }
- QLineEdit, QComboBox, QTextEdit {
- padding: 5px;
- border: 1px solid #ccc;
- border-radius: 4px;
- }
- """
- def parse_version_type(version_id):
- """
- Rozpoznaje typ wersji Minecrafta (release, snapshot, alpha, beta) i zwraca krotkę (major, minor, patch).
- Zwraca też flagę, czy wersja wymaga nowoczesnych argumentów (>=1.6).
- """
- # Snapshoty (np. 25w19a)
- snapshot_match = re.match(r"(\d{2})w(\d{2})[a-z]", version_id)
- if snapshot_match:
- year = int(snapshot_match.group(1))
- week = int(snapshot_match.group(2))
- # Przyjmujemy, że snapshoty z 2025 to >= 1.21
- major, minor = 1, 21
- patch = 0
- modern_args = True
- logging.debug(f"Wersja {version_id} rozpoznana jako snapshot, zakładam {major}.{minor}.{patch}")
- return (major, minor, patch), modern_args
- # Standardowe wersje (np. 1.20.4)
- standard_match = re.match(r"(\d+)\.(\d+)(?:\.(\d+))?", version_id)
- if standard_match:
- major = int(standard_match.group(1))
- minor = int(standard_match.group(2))
- patch = int(standard_match.group(3) or 0)
- modern_args = (major, minor) >= (1, 6)
- logging.debug(f"Wersja {version_id} rozpoznana jako standardowa, krotka: {major}.{minor}.{patch}")
- return (major, minor, patch), modern_args
- # Alpha/Beta (np. a1.0.16, b1.7.3)
- old_match = re.match(r"[ab](\d+\.\d+\.\d+)", version_id)
- if old_match:
- logging.debug(f"Wersja {version_id} rozpoznana jako alpha/beta, zakładam 1.0.0")
- return (1, 0, 0), False
- # Fallback
- logging.warning(f"Nie rozpoznano wersji {version_id}, zakładam 1.0.0")
- return (1, 0, 0), False
- # Nowa klasa wątku do pobierania ikon
- class IconDownloadThread(QThread):
- # Sygnał emitowany po pobraniu ikony: (mod_id, ścieżka_do_pliku)
- icon_downloaded = pyqtSignal(int, str)
- def __init__(self, mod_id, url, dest_path):
- super().__init__()
- self.mod_id = mod_id
- self.url = url
- self.dest_path = Path(dest_path)
- # logging.debug(f"Utworzono wątek pobierania ikony dla mod ID {mod_id} z URL: {url}")
- def run(self):
- # Sprawdź jeszcze raz, czy plik nie został utworzony przez inny wątek w międzyczasie
- if self.dest_path.exists():
- logging.debug(f"Icon file already exists for mod ID {self.mod_id}: {self.dest_path.name}. Skipping download.")
- self.icon_downloaded.emit(self.mod_id, str(self.dest_path))
- return
- logging.debug(f"Starting icon download for mod ID {self.mod_id} from {self.url}")
- try:
- self.dest_path.parent.mkdir(parents=True, exist_ok=True)
- response = requests.get(self.url, timeout=10) # Krótki timeout dla ikon
- response.raise_for_status()
- with open(self.dest_path, "wb") as f:
- f.write(response.content)
- logging.debug(f"Icon downloaded successfully for mod ID {self.mod_id}: {self.dest_path.name}")
- self.icon_downloaded.emit(self.mod_id, str(self.dest_path))
- except requests.exceptions.RequestException as e:
- logging.warning(f"Failed to download icon for mod ID {self.mod_id} from {self.url}: {e}")
- # Emituj pustą ścieżkę lub None, aby wskazać błąd
- self.icon_downloaded.emit(self.mod_id, "")
- except Exception as e:
- logging.error(f"Unexpected error during icon download for mod ID {self.mod_id}: {e}")
- self.icon_downloaded.emit(self.mod_id, "")
- class DownloadThread(QThread):
- progress = pyqtSignal(int)
- total_progress = pyqtSignal(int)
- finished = pyqtSignal(str, bool, str)
- update_status = pyqtSignal(str, str)
- update_speed = pyqtSignal(float, str)
- update_size = pyqtSignal(float, str)
- add_to_total_files = pyqtSignal(int)
- def __init__(self, url, dest, download_type, sha1=None):
- super().__init__()
- self.url = url
- self.dest = dest
- self.download_type = download_type
- self.sha1 = sha1
- self.canceled = False
- def run(self):
- dest_path = Path(self.dest)
- file_name = os.path.basename(self.dest)
- logging.info(f"Starting download thread for: {self.url} -> {self.dest}")
- if dest_path.exists() and self.sha1 and self.validate_sha1(self.dest, self.sha1):
- logging.info(f"File {file_name} already exists and is valid. Skipping download.")
- self.progress.emit(100)
- self.finished.emit(self.dest, True, "skipped")
- return
- try:
- dest_path.parent.mkdir(parents=True, exist_ok=True)
- self.update_status.emit(self.download_type, file_name)
- response = requests.get(self.url, stream=True, timeout=120)
- response.raise_for_status()
- total_size = int(response.headers.get("content-length", 0))
- if total_size > 0:
- unit, size = self._format_bytes(total_size)
- self.update_size.emit(size, unit)
- else:
- self.update_size.emit(0, "Nieznany")
- downloaded = 0
- start_time = time.time()
- last_update_time = start_time
- last_downloaded_speed_check = 0
- progress_interval = 0.5
- with open(self.dest, "wb") as f:
- for chunk in response.iter_content(chunk_size=8192):
- if self.canceled:
- logging.info(f"Download canceled for {file_name}")
- try:
- dest_path.unlink(missing_ok=True)
- except Exception as cleanup_e:
- logging.warning(f"Failed to clean up partial download {dest_path}: {cleanup_e}") # Poprawiono zmienną
- self.finished.emit("", False, "Anulowano")
- return
- if chunk:
- f.write(chunk)
- downloaded += len(chunk)
- current_time = time.time()
- if current_time - last_update_time >= progress_interval and total_size > 0:
- self.progress.emit(int(downloaded / total_size * 100))
- delta_downloaded = downloaded - last_downloaded_speed_check
- delta_time = current_time - last_update_time
- if delta_time > 0:
- speed = delta_downloaded / delta_time
- speed_unit, speed_val = self._format_bytes(speed)
- self.update_speed.emit(speed_val, f"{speed_unit}/s")
- last_downloaded_speed_check = downloaded
- last_update_time = current_time
- if total_size > 0:
- self.progress.emit(100)
- else:
- self.progress.emit(100)
- if self.sha1 and not self.validate_sha1(self.dest, self.sha1):
- logging.error(f"SHA1 validation failed for {file_name}. Expected: {self.sha1}")
- try:
- dest_path.unlink(missing_ok=True)
- except Exception as cleanup_e:
- logging.warning(f"Failed to clean up invalid file {dest_path}: {cleanup_e}") # Poprawiono zmienną
- self.finished.emit(self.dest, False, "Walidacja SHA1 nieudana")
- return
- logging.info(f"Download successful: {file_name}")
- self.finished.emit(self.dest, True, "")
- except requests.exceptions.Timeout:
- logging.error(f"Download timeout for {file_name}")
- try:
- dest_path.unlink(missing_ok=True)
- except Exception as cleanup_e:
- logging.warning(f"Failed to clean up partial download {dest_path}: {cleanup_e}") # Poprawiono zmienną
- self.finished.emit(self.dest, False, "Timeout pobierania")
- except requests.exceptions.RequestException as e:
- logging.error(f"Download error for {file_name}: {e}")
- try:
- dest_path.unlink(missing_ok=True)
- except Exception as cleanup_e:
- logging.warning(f"Failed to clean up partial download {dest_path}: {cleanup_e}") # Poprawiono zmienną
- self.finished.emit(self.dest, False, f"Błąd pobierania: {e}")
- except Exception as e:
- logging.error(f"An unexpected error occurred during download of {file_name}: {e}")
- try:
- dest_path.unlink(missing_ok=True)
- except Exception as cleanup_e:
- logging.warning(f"Failed to clean up partial download {dest_path}: {cleanup_e}") # Poprawiono zmienną
- self.finished.emit(self.dest, False, f"Nieoczekiwany błąd: {e}")
- def validate_sha1(self, file_path, expected_sha1):
- sha1 = hashlib.sha1()
- try:
- if not expected_sha1 or not re.match(r'^[a-f0-9]{40}$', expected_sha1):
- logging.warning(f"SHA1 validation skipped for {Path(file_path).name}: Invalid or missing SHA1 hash provided.")
- return True
- with open(file_path, "rb") as f:
- for chunk in iter(lambda: f.read(8192), b""):
- sha1.update(chunk)
- calculated_sha1 = sha1.hexdigest()
- is_valid = calculated_sha1 == expected_sha1
- if not is_valid:
- logging.warning(f"SHA1 mismatch for {Path(file_path).name}. Expected: {expected_sha1}, Got: {calculated_sha1}")
- return is_valid
- except FileNotFoundError:
- logging.warning(f"SHA1 validation failed: file not found {file_path}")
- return False
- except Exception as e:
- logging.error(f"Error during SHA1 validation for {file_path}: {e}")
- return False
- def cancel(self):
- self.canceled = True
- def _format_bytes(self, byte_count):
- if byte_count is None or byte_count < 0:
- return "B", 0
- units = ["B", "KB", "MB", "GB", "TB"]
- i = 0
- while byte_count >= 1024 and i < len(units) - 1:
- byte_count /= 1024
- i += 1
- return units[i], byte_count
- class DownloadProgressDialog(QDialog):
- cancel_signal = pyqtSignal()
- download_process_finished = pyqtSignal(bool)
- # Dodano parametr launcher
- def __init__(self, launcher, parent=None):
- super().__init__(parent)
- self.launcher = launcher # Przypisano launcher
- self.setWindowTitle("Pobieranie zasobów...")
- self.setMinimumSize(500, 400)
- self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
- self.total_files_expected = 0
- self.downloaded_files_count = 0
- self.successful_files_count = 0
- self.skipped_files_count = 0
- self.failed_downloads = []
- self.is_cancelled = False
- self.init_ui()
- self._dialog_closed_by_signal = False
- def init_ui(self):
- layout = QVBoxLayout()
- layout.setSpacing(10)
- # Etykieta statusu
- self.status_label = QLabel("Pobieranie...")
- layout.addWidget(self.status_label)
- # Pasek postępu pliku
- self.file_progress_bar = QProgressBar()
- self.file_progress_bar.setValue(0)
- layout.addWidget(self.file_progress_bar)
- # Pasek postępu całkowitego
- self.total_progress_bar = QProgressBar()
- self.total_progress_bar.setValue(0)
- layout.addWidget(self.total_progress_bar)
- # Informacje o plikach
- self.files_label = QLabel("Pliki: 0/0 (0 pominięto)")
- layout.addWidget(self.files_label)
- # Prędkość
- self.speed_label = QLabel("Prędkość: 0 KB/s")
- layout.addWidget(self.speed_label)
- # Rozmiar
- self.size_label = QLabel("Rozmiar: ---")
- layout.addWidget(self.size_label)
- # Lista plików
- self.file_list = QListWidget()
- self.file_list.addItem("Oczekiwanie na rozpoczęcie...")
- layout.addWidget(self.file_list)
- # Przyciski
- self.cancel_button = QPushButton("Anuluj")
- self.cancel_button.clicked.connect(self.cancel_downloads)
- layout.addWidget(self.cancel_button)
- self.close_button = QPushButton("Zamknij")
- self.close_button.clicked.connect(self.accept)
- self.close_button.setVisible(False)
- layout.addWidget(self.close_button)
- self.setLayout(layout)
- def set_total_files(self, count):
- self.total_files_expected = count
- self.files_label.setText(f"Pliki: {self.downloaded_files_count}/{self.total_files_expected} ({self.skipped_files_count} pominięto)")
- self.update_total_progress()
- def add_total_files(self, count):
- self.total_files_expected += count
- self.files_label.setText(f"Pliki: {self.downloaded_files_count}/{self.total_files_expected} ({self.skipped_files_count} pominięto)")
- def update_status(self, download_type, file_name):
- self.status_label.setText(f"Pobieranie {download_type}: {file_name}")
- self.file_list.clear()
- self.file_list.addItem(f"{file_name} ({download_type})")
- def update_progress(self, value):
- self.file_progress_bar.setValue(value)
- def update_total_progress(self):
- if self.total_files_expected > 0:
- total_finished = self.downloaded_files_count + self.skipped_files_count
- total_percentage = int((total_finished / self.total_files_expected) * 100)
- self.total_progress_bar.setValue(total_percentage)
- else:
- pass
- def update_speed(self, speed, unit):
- self.speed_label.setText(f"Prędkość: {speed:.2f} {unit}")
- def update_size(self, size, unit):
- self.size_label.setText(f"Rozmiar: {size:.2f} {unit}" if unit != "Nieznany" else "Rozmiar: Nieznany")
- def increment_downloaded(self, file_path, success=True, error_msg=""):
- file_name = os.path.basename(file_path) if file_path else "Nieznany plik"
- if error_msg == "skipped":
- self.skipped_files_count += 1
- self.successful_files_count += 1
- logging.info(f"File skipped: {file_name}")
- else:
- self.downloaded_files_count += 1
- if success:
- self.successful_files_count += 1
- logging.info(f"Download finished: {file_name}")
- else:
- self.failed_downloads.append(f"{file_name} ({error_msg})")
- logging.error(f"Download failed: {file_name} - {error_msg}")
- self.files_label.setText(f"Pliki: {self.downloaded_files_count}/{self.total_files_expected} ({self.skipped_files_count} pominięto)")
- self.update_total_progress()
- self.file_list.clear()
- self.file_list.addItem("Oczekiwanie na następny...")
- total_finished = self.downloaded_files_count + self.skipped_files_count
- if total_finished >= self.total_files_expected and self.total_files_expected > 0:
- self.on_all_downloads_finished()
- elif self.is_cancelled:
- pass
- def on_all_downloads_finished(self):
- logging.info("All downloads/checks processed.")
- self.status_label.setText("Pobieranie zakończone!")
- self.total_progress_bar.setValue(100)
- self.file_progress_bar.setValue(100)
- self.speed_label.setText("Prędkość: 0 KB/s")
- self.size_label.setText("Rozmiar: ---")
- self.file_list.clear()
- self.file_list.addItem("Wszystkie pliki przetworzone.")
- self.cancel_button.setVisible(False)
- self.close_button.setVisible(True)
- self.close_button.setEnabled(True)
- overall_success = not self.failed_downloads and not self.is_cancelled
- if self.failed_downloads:
- msg = "Niektóre pliki nie zostały pobrane:\n" + "\n".join(self.failed_downloads)
- QMessageBox.warning(self, "Pobieranie zakończone z błędami", msg)
- logging.warning("Download finished with errors.")
- elif self.is_cancelled:
- self.status_label.setText("Pobieranie anulowane!")
- logging.warning("Download process cancelled by user.")
- else:
- self.status_label.setText("Pobieranie zakończone pomyślnie!")
- logging.info("Download finished successfully.")
- QTimer.singleShot(100, lambda: self._emit_download_process_finished(overall_success))
- def _emit_download_process_finished(self, success):
- if not self._dialog_closed_by_signal:
- self._dialog_closed_by_signal = True
- self.download_process_finished.emit(success)
- logging.debug(f"Emitted download_process_finished({success})")
- def cancel_downloads(self):
- if self.is_cancelled:
- return
- self.is_cancelled = True
- self.cancel_button.setEnabled(False)
- self.status_label.setText("Anulowanie...")
- logging.info("User requested cancellation.")
- self.cancel_signal.emit()
- self.close_button.setVisible(True)
- self.close_button.setEnabled(True)
- self.file_list.clear()
- self.file_list.addItem("Anulowano przez użytkownika.")
- def closeEvent(self, event):
- total_finished = self.downloaded_files_count + self.skipped_files_count
- # Użyj przypisanego self.launcher
- downloads_pending = total_finished < self.total_files_expected or (self.launcher and self.launcher.current_download_thread)
- if downloads_pending and not self.is_cancelled:
- reply = QMessageBox.question(self, "Zamknąć?",
- "Pobieranie wciąż trwa. Czy na pewno chcesz zamknąć i anulować?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
- if reply == QMessageBox.StandardButton.Yes:
- self.cancel_downloads()
- event.accept()
- if not self._dialog_closed_by_signal:
- self._emit_download_process_finished(False)
- else:
- event.ignore()
- else:
- if not self._dialog_closed_by_signal:
- final_success = not self.failed_downloads and not self.is_cancelled
- self._emit_download_process_finished(final_success)
- event.accept()
- class MinecraftLauncher:
- def __init__(self):
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
- ASSETS_DIR.mkdir(parents=True, exist_ok=True)
- LIBRARIES_DIR.mkdir(parents=True, exist_ok=True)
- INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
- JAVA_DIR.mkdir(parents=True, exist_ok=True)
- MOD_ICONS_DIR.mkdir(parents=True, exist_ok=True)
- self.settings = self.load_settings()
- self.accounts = []
- self.java_versions = self.find_java_versions()
- self.download_queue = deque()
- self.current_download_thread = None
- self.progress_dialog = None
- self.logged_snapshots_modloader_warning = set()
- self._post_download_data = None
- def load_settings(self):
- if SETTINGS_FILE.exists():
- try:
- with SETTINGS_FILE.open("r") as f:
- loaded_settings = json.load(f)
- settings = DEFAULT_SETTINGS.copy()
- settings.update(loaded_settings)
- return settings
- except (json.JSONDecodeError, Exception) as e:
- logging.error(f"Błąd odczytu/parsowania settings.json: {e}. Używam domyślnych.")
- logging.info("settings.json nie znaleziono lub błąd odczytu, używam domyślnych.")
- return DEFAULT_SETTINGS.copy()
- def save_settings(self):
- try:
- with SETTINGS_FILE.open("w") as f:
- json.dump(self.settings, f, indent=4)
- logging.info("Ustawienia zapisane pomyślnie.")
- except Exception as e:
- logging.error(f"Błąd zapisu ustawień: {e}")
- def _queue_download(self, url, dest, download_type, sha1=None):
- dest_path = Path(dest)
- temp_validator = DownloadThread(url, dest, download_type, sha1)
- if dest_path.exists() and sha1 and temp_validator.validate_sha1(str(dest_path), sha1):
- logging.info(f"Plik {Path(dest).name} już istnieje i jest poprawny. Pomijanie kolejkowania.")
- return 0
- else:
- dest_path.parent.mkdir(parents=True, exist_ok=True)
- self.download_queue.append((url, str(dest_path), download_type, sha1))
- logging.debug(f"Dodano do kolejki: {Path(dest).name} ({download_type})")
- return 1
- def process_download_queue(self):
- if self.current_download_thread is None and self.download_queue:
- url, dest, download_type, sha1 = self.download_queue.popleft()
- self.current_download_thread = DownloadThread(url, dest, download_type, sha1)
- if self.progress_dialog:
- try:
- self.current_download_thread.progress.disconnect()
- self.current_download_thread.update_status.disconnect()
- self.current_download_thread.update_speed.disconnect()
- self.current_download_thread.update_size.disconnect()
- self.current_download_thread.finished.disconnect()
- except TypeError:
- pass
- self.current_download_thread.progress.connect(self.progress_dialog.update_progress)
- self.current_download_thread.update_status.connect(self.progress_dialog.update_status)
- self.current_download_thread.update_speed.connect(self.progress_dialog.update_speed)
- self.current_download_thread.update_size.connect(self.progress_dialog.update_size)
- self.current_download_thread.finished.connect(self.on_download_thread_finished)
- logging.debug(f"Starting download thread for: {Path(dest).name}")
- self.current_download_thread.start()
- elif self.current_download_thread is None and not self.download_queue:
- logging.debug("Download queue is empty and no thread active. Download process should be complete.")
- pass
- def on_download_thread_finished(self, path, success, error_message):
- file_name = os.path.basename(path) if path else "Unknown File"
- logging.debug(f"Download thread finished for {file_name}. Success: {success}, Error: {error_message}")
- if self.progress_dialog:
- QTimer.singleShot(0, lambda: self.progress_dialog.increment_downloaded(path, success, error_message))
- if self.current_download_thread:
- self.current_download_thread.deleteLater()
- self.current_download_thread = None
- self.process_download_queue()
- def cancel_downloads(self):
- logging.info("Attempting to cancel downloads.")
- if self.current_download_thread:
- self.current_download_thread.cancel()
- self.download_queue.clear()
- logging.info("Download queue cleared.")
- def _download_metadata_sync(self, url, dest, description):
- dest_path = Path(dest)
- logging.info(f"Pobieranie metadanych (synchronicznie): {description} z {url}")
- dest_path.parent.mkdir(parents=True, exist_ok=True)
- try:
- response = requests.get(url, timeout=30)
- response.raise_for_status()
- with open(dest_path, "wb") as f:
- f.write(response.content)
- logging.info(f"Pobrano metadane: {dest_path.name}")
- return str(dest_path)
- except requests.exceptions.RequestException as e:
- logging.error(f"Błąd pobierania metadanych {description} z {url}: {e}")
- raise ValueError(f"Nie udało się pobrać metadanych {description}: {e}")
- except Exception as e:
- logging.error(f"Nieoczekiwany błąd podczas pobierania metadanych {description}: {e}")
- raise RuntimeError(f"Nieoczekiwany błąd podczas pobierania metadanych {description}: {e}")
- def _queue_version_files(self, version_id, instance_dir):
- version_dir = Path(instance_dir) / "versions" / version_id
- version_dir.mkdir(parents=True, exist_ok=True)
- queued_count = 0
- try:
- try:
- manifest = self.get_version_manifest()
- version_info = next((v for v in manifest["versions"] if v["id"] == version_id), None)
- if not version_info:
- raise ValueError(f"Wersja {version_id} nie istnieje w manifeście!")
- version_json_url = version_info["url"]
- except Exception as e:
- raise ValueError(f"Nie udało się uzyskać URL manifestu wersji dla {version_id}: {e}")
- version_json_path = version_dir / f"{version_id}.json"
- self._download_metadata_sync(version_json_url, version_json_path, "version JSON")
- try:
- with version_json_path.open("r", encoding='utf-8') as f:
- version_data = json.load(f)
- except json.JSONDecodeError as e:
- logging.error(f"Błąd parsowania {version_json_path}: {e}")
- raise ValueError(f"Nieprawidłowy plik wersji JSON: {version_json_path}")
- client_info = version_data.get("downloads", {}).get("client")
- if client_info:
- client_url = client_info["url"]
- client_sha1 = client_info.get("sha1")
- client_path = version_dir / f"{version_id}.jar"
- queued_count += self._queue_download(client_url, client_path, "client JAR", client_sha1)
- else:
- logging.warning(f"Brak danych klienta JAR w JSON dla wersji {version_id}. Kontynuuję bez kolejkowania client.jar")
- asset_index_info = version_data.get("assetIndex")
- if not asset_index_info:
- logging.warning(f"Brak danych assetIndex w JSON dla wersji {version_id}. Kontynuuję bez kolejkowania assetów.")
- asset_data = {}
- else:
- asset_index_id = asset_index_info["id"]
- asset_index_url = asset_index_info["url"]
- asset_index_sha1 = asset_index_info.get("sha1")
- asset_index_path = Path(ASSETS_DIR) / "indexes" / f"{asset_index_id}.json"
- self._download_metadata_sync(asset_index_url, asset_index_path, "asset index")
- try:
- with asset_index_path.open("r", encoding='utf-8') as f:
- asset_data = json.load(f)
- except json.JSONDecodeError as e:
- logging.error(f"Błąd parsowania {asset_index_path}: {e}")
- raise ValueError(f"Nieprawidłowy plik indexu assetów: {asset_index_path}")
- except FileNotFoundError:
- logging.warning(f"Plik indexu assetów nie znaleziono po pobraniu (??): {asset_index_path}")
- asset_data = {}
- for hash_path, info in asset_data.get("objects", {}).items():
- hash = info["hash"]
- obj_path = Path(ASSETS_DIR) / "objects" / hash[:2] / hash
- obj_url = f"https://resources.download.minecraft.net/{hash[:2]}/{hash}"
- queued_count += self._queue_download(obj_url, obj_path, "asset", hash)
- for lib in version_data.get("libraries", []):
- if self._is_library_applicable(lib):
- if "downloads" in lib and "artifact" in lib["downloads"]:
- artifact = lib["downloads"]["artifact"]
- lib_path = Path(LIBRARIES_DIR) / artifact["path"]
- queued_count += self._queue_download(artifact["url"], lib_path, "library", artifact.get("sha1"))
- classifiers = lib["downloads"].get("classifiers", {})
- native_classifier_data = None
- current_os_key = sys.platform
- if current_os_key == "win32":
- current_os_key = "windows"
- elif current_os_key == "darwin":
- current_os_key = "macos"
- elif current_os_key.startswith("linux"):
- current_os_key = "linux"
- arch = '64' if sys.maxsize > 2**32 else '32'
- search_keys = [
- f"natives-{current_os_key}-{arch}",
- f"natives-{current_os_key}",
- ]
- for key in search_keys:
- if key in classifiers:
- native_classifier_data = classifiers[key]
- logging.debug(f"Znaleziono klasyfikator natywny '{key}' dla biblioteki {lib.get('name', 'Nieznana')}")
- break
- if native_classifier_data:
- native_url = native_classifier_data["url"]
- native_sha1 = native_classifier_data.get("sha1")
- lib_name = lib.get("name", "unknown_lib").replace(":", "_").replace(".", "_")
- classifier_file_name = Path(native_classifier_data["path"]).name
- native_zip_path = version_dir / "natives_zips" / f"{lib_name}_{classifier_file_name}"
- queued_count += self._queue_download(native_url, native_zip_path, "native zip", native_sha1)
- else:
- logging.debug(f"Brak natywnego klasyfikatora dla biblioteki {lib.get('name', 'Nieznana')} dla systemu {current_os_key}-{arch}")
- except ValueError as e:
- logging.error(f"Błąd podczas kolejkowania plików wersji: {e}")
- raise
- except RuntimeError as e:
- logging.error(f"Błąd krytyczny podczas kolejkowania plików wersji: {e}")
- raise
- except Exception as e:
- logging.error(f"Nieoczekiwany błąd podczas kolejkowania plików wersji: {e}")
- raise
- return queued_count
- def validate_modloader(self, modloader, version_id):
- if not version_id: return False
- is_snapshot = re.match(r"^\d+w\d+[a-z]$", version_id)
- if is_snapshot:
- if version_id not in self.logged_snapshots_modloader_warning:
- logging.warning(f"Wersja {version_id} to snapshot, wsparcie modloaderów jest ograniczone lub nieistniejące.")
- self.logged_snapshots_modloader_warning.add(version_id)
- return False
- try:
- parts = version_id.split('.')
- major = int(parts[0])
- minor = int(parts[1]) if len(parts) > 1 else 0
- patch = int(parts[2]) if len(parts) > 2 else 0
- version_tuple = (major, minor, patch)
- version_tuple += (0,) * (3 - len(version_tuple))
- except ValueError:
- logging.error(f"Nie można sparsować wersji Minecrafta '{version_id}' dla walidacji modloadera. Zakładam brak wsparcia.")
- return False
- if modloader == "forge":
- if version_tuple < (1, 5, 2): return False
- elif modloader == "neoforge":
- if version_tuple < (1, 20, 1): return False
- elif modloader == "fabric":
- if version_tuple < (1, 14, 0): return False
- elif modloader == "quilt":
- if version_tuple < (1, 14, 0): return False
- return True
- def _queue_modloader_installer(self, modloader, version_id, instance_dir):
- if not self.validate_modloader(modloader, version_id):
- raise ValueError(f"{modloader.capitalize()} prawdopodobnie nie wspiera wersji {version_id}!")
- modloader_dir = Path(instance_dir) / "modloaders"
- modloader_dir.mkdir(parents=True, exist_ok=True)
- queued_count = 0
- url = None
- installer_name = None
- if modloader in ["forge", "neoforge"]:
- logging.warning(f"Automatyczne pobieranie instalatorów {modloader.capitalize()} nie jest wspierane w tym uproszczonym launcherze. Umieść plik instalatora JAR ręcznie w katalogu: {modloader_dir}")
- pass
- elif modloader == "fabric":
- installer_name = "fabric-installer.jar"
- url = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/0.11.2/fabric-installer-0.11.2.jar"
- installer_path = modloader_dir / installer_name
- queued_count += self._queue_download(url, installer_path, "fabric installer")
- elif modloader == "quilt":
- installer_name = "quilt-installer.jar"
- url = "https://maven.quiltmc.org/repository/release/org/quiltmc/quilt-installer/0.10.0/quilt-installer-0.10.0.jar"
- installer_path = modloader_dir / installer_name
- queued_count += self._queue_download(url, installer_path, "quilt installer")
- else:
- raise ValueError(f"Nieznany modloader: {modloader}")
- return queued_count
- def _run_modloader_installer(self, modloader, version_id, instance_dir):
- modloader_dir = Path(instance_dir) / "modloaders"
- installer_path = None
- installer_args = []
- success_message = ""
- error_message = ""
- java_path = self.find_java_for_version(version_id)
- if not java_path or not Path(java_path).exists():
- raise ValueError(f"Nie znaleziono kompatybilnej Javy dla wersji {version_id} lub ścieżka Javy jest niepoprawna. Zainstaluj Javę i/lub sprawdź ustawienia.")
- if modloader == "forge":
- forge_installers = list(modloader_dir.glob("forge-*-installer.jar"))
- if not forge_installers:
- raise FileNotFoundError(f"Nie znaleziono instalatora Forge (.jar) w katalogu instancji: {modloader_dir}. Pobierz go ręcznie i umieść w tym katalogu.")
- if len(forge_installers) > 1:
- logging.warning(f"Znaleziono wiele plików instalatora Forge w {modloader_dir}. Używam pierwszego: {forge_installers[0].name}")
- installer_path = forge_installers[0]
- installer_args = ["--installClient", str(instance_dir)]
- success_message = f"Zainstalowano Forge dla wersji {version_id}"
- error_message = "Błąd instalacji Forge."
- elif modloader == "neoforge":
- neoforge_installers = list(modloader_dir.glob("neoforge-*-installer.jar"))
- if not neoforge_installers:
- raise FileNotFoundError(f"Nie znaleziono instalatora NeoForge (.jar) w katalogu instancji: {modloader_dir}. Pobierz go ręcznie i umieść w tym katalogu.")
- if len(neoforge_installers) > 1:
- logging.warning(f"Znaleziono wiele plików instalatora NeoForge w {modloader_dir}. Używam pierwszego: {neoforge_installers[0].name}")
- installer_path = neoforge_installers[0]
- installer_args = ["--installClient", str(instance_dir)]
- success_message = f"Zainstalowano NeoForge dla wersji {version_id}"
- error_message = "Błąd instalacji NeoForge."
- elif modloader == "fabric":
- installer_path = modloader_dir / "fabric-installer.jar"
- installer_args = ["client", "-mcversion", version_id, "-dir", str(instance_dir)]
- success_message = f"Zainstalowano Fabric dla wersji {version_id}"
- error_message = "Błąd instalacji Fabric."
- elif modloader == "quilt":
- installer_path = modloader_dir / "quilt-installer.jar"
- installer_args = ["install", "client", version_id, "--install-dir", str(instance_dir)]
- success_message = f"Zainstalowano Quilt dla wersji {version_id}"
- error_message = "Błąd instalacji Quilt."
- else:
- logging.error(f"Próba uruchomienia instalatora dla nieznanego modloadera: {modloader}")
- return
- if installer_path is None or not installer_path.exists():
- if modloader in ["fabric", "quilt"]:
- raise FileNotFoundError(f"Instalator {modloader.capitalize()} (.jar) nie znaleziono w katalogu: {installer_path}. Pobieranie mogło się nie udać.")
- else:
- raise FileNotFoundError(f"Instalator modloadera nie znaleziono: {installer_path}")
- cmd = [java_path, "-jar", str(installer_path)] + installer_args
- logging.info(f"Uruchamianie instalatora modloadera: {' '.join([str(c) for c in cmd])}")
- try:
- result = subprocess.run(cmd, cwd=str(modloader_dir), capture_output=True, text=True, timeout=300)
- logging.info(f"Instalator stdout:\n{result.stdout}")
- logging.info(f"Instalator stderr:\n{result.stderr}")
- if result.returncode != 0:
- detailed_error = f"{error_message} Proces zakończył się kodem {result.returncode}.\nStderr:\n{result.stderr}"
- raise subprocess.CalledProcessError(result.returncode, cmd, output=result.stdout, stderr=result.stderr)
- logging.info(success_message)
- if modloader in ["fabric", "quilt"]:
- try:
- installer_path.unlink()
- logging.debug(f"Usunięto instalator: {installer_path}")
- except Exception as e:
- logging.warning(f"Nie udało się usunąć instalatora {installer_path}: {e}")
- except FileNotFoundError:
- logging.error(f"Plik wykonywalny instalatora lub Javy nie istnieje: {installer_path} lub {java_path}")
- raise FileNotFoundError(f"Plik wykonywalny instalatora modloadera lub Javy nie istnieje. Sprawdź ścieżki.")
- except subprocess.TimeoutExpired:
- logging.error(f"Instalator modloadera przekroczył czas oczekiwania (Timeout).")
- raise TimeoutError(f"Instalator modloadera przekroczył czas oczekiwania. Spróbuj ponownie lub zwiększ limit czasu.")
- except subprocess.CalledProcessError as e:
- logging.error(f"Instalator modloadera zakończył się błędem:\n{e.stderr}\n{e}")
- raise ValueError(f"Instalator modloadera zakończył się błędem (Kod: {e.returncode}). Sprawdź logi lub dane wyjściowe instalatora.")
- except Exception as e:
- logging.error(f"Nieoczekiwany błąd podczas uruchamiania instalatora modloadera: {e}")
- raise RuntimeError(f"Nieoczekiwany błąd podczas uruchamiania instalatora modloadera: {e}")
- def get_version_manifest(self):
- url = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"
- manifest_path = CONFIG_DIR / "version_manifest_v2.json"
- if manifest_path.exists():
- try:
- with manifest_path.open("r", encoding='utf-8') as f:
- manifest_data = json.load(f)
- if datetime.fromtimestamp(manifest_path.stat().st_mtime) > datetime.now() - timedelta(hours=1):
- logging.info("Używam cache version_manifest_v2.json")
- return manifest_data
- except Exception as e:
- logging.warning(f"Błąd odczytu cache manifestu wersji: {e}. Pobieram nowy.")
- try:
- logging.info(f"Pobieranie manifestu wersji z: {url}")
- response = requests.get(url, timeout=15)
- response.raise_for_status()
- manifest_data = response.json()
- try:
- with manifest_path.open("w", encoding='utf-8') as f:
- json.dump(manifest_data, f, indent=4)
- logging.info("Zapisano version_manifest_v2.json do cache.")
- except Exception as e:
- logging.warning(f"Błąd zapisu cache manifestu wersji: {e}")
- return manifest_data
- except requests.exceptions.RequestException as e:
- logging.error(f"Błąd pobierania manifestu wersji: {e}")
- if manifest_path.exists():
- try:
- with manifest_path.open("r", encoding='utf-8') as f:
- logging.warning("Pobieranie nieudane, używam starego cache manifestu wersji.")
- return json.load(f)
- except Exception as e_cache:
- logging.error(f"Błąd odczytu starego cache manifestu wersji: {e_cache}")
- raise ConnectionError("Brak połączenia z internetem i brak dostępnego manifestu wersji!")
- else:
- raise ConnectionError("Brak połączenia z internetem i brak dostępnego manifestu wersji!")
- def get_curseforge_mods(self, search_query, version_id):
- headers = {"x-api-key": CURSEFORGE_API_KEY}
- params = {"gameId": 432, "searchFilter": search_query, "minecraftVersion": version_id, "classId": 6, "sortField": 2}
- url = "https://api.curseforge.com/v1/mods/search"
- logging.info(f"Wyszukiwanie modów: '{search_query}' dla wersji {version_id}")
- try:
- response = requests.get(url, headers=headers, params=params, timeout=15)
- response.raise_for_status()
- data = response.json().get("data", [])
- logging.info(f"Znaleziono {len(data)} modów dla '{search_query}'")
- return data
- except requests.exceptions.RequestException as e:
- logging.error(f"Błąd wyszukiwania modów z CurseForge: {e}")
- if hasattr(e, 'response') and e.response is not None:
- logging.error(f"CurseForge API Response status: {e.response.status_code}, body: {e.response.text}")
- if e.response.status_code == 403:
- raise PermissionError("Błąd API CurseForge: Klucz API jest nieprawidłowy lub brak dostępu.")
- if e.response.status_code == 429:
- raise requests.exceptions.RequestException("Błąd API CurseForge: Limit żądań przekroczony. Spróbuj ponownie później.")
- raise requests.exceptions.RequestException(f"Nie udało się wyszukać modów: {e}")
- def _queue_curseforge_mod_files(self, mod_id, version_id, instance_dir, download_dependencies=False, visited_mods=None):
- if visited_mods is None:
- visited_mods = set()
- if mod_id in visited_mods:
- logging.debug(f"Mod ID {mod_id} już przetworzony, pomijam.")
- return 0
- visited_mods.add(mod_id)
- logging.debug(f"Processing mod ID {mod_id} for version {version_id}")
- headers = {"x-api-key": CURSEFORGE_API_KEY}
- total_queued = 0
- try:
- files_url = f"https://api.curseforge.com/v1/mods/{mod_id}/files"
- response = requests.get(files_url, headers=headers, timeout=15)
- response.raise_for_status()
- files = response.json().get("data", [])
- compatible_file = None
- files.sort(key=lambda x: x.get('fileDate', '1970-01-01T00:00:00Z'), reverse=True)
- for file in files:
- if version_id in file.get("gameVersions", []):
- compatible_file = file
- break
- if not compatible_file:
- try:
- mod_info_resp = requests.get(f"https://api.curseforge.com/v1/mods/{mod_id}", headers=headers, timeout=5)
- mod_name = mod_info_resp.json().get("data", {}).get("name", f"ID {mod_id}") if mod_info_resp.status_code == 200 else f"ID {mod_id}"
- except Exception:
- mod_name = f"ID {mod_id}"
- logging.warning(f"Brak kompatybilnego pliku moda dla {mod_name} (ID: {mod_id}) i wersji {version_id}.")
- return 0
- mod_url = compatible_file.get("downloadUrl")
- mod_name = compatible_file.get("fileName")
- if not mod_url:
- logging.warning(f"Download URL is null for mod file {mod_name} (Mod ID: {mod_id}). Skipping.")
- return 0
- mod_path = Path(instance_dir) / "mods" / mod_name
- mod_path.parent.mkdir(parents=True, exist_ok=True)
- total_queued += self._queue_download(mod_url, mod_path, "mod")
- if download_dependencies:
- logging.debug(f"Checking dependencies for mod ID {mod_id}")
- for dep in compatible_file.get("dependencies", []):
- if dep.get("relationType") == 3:
- dep_mod_id = dep.get("modId")
- if dep_mod_id:
- logging.debug(f"Queueing required dependency ID {dep_mod_id} for mod ID {mod_id}")
- total_queued += self._queue_curseforge_mod_files(dep_mod_id, version_id, instance_dir, download_dependencies=True, visited_mods=visited_mods)
- else:
- logging.warning(f"Dependency found with null modId for mod ID {mod_id}. Skipping.")
- except requests.exceptions.RequestException as e:
- logging.error(f"Błąd pobierania plików moda dla mod ID {mod_id}: {e}")
- return 0
- except Exception as e:
- logging.error(f"Nieoczekiwany błąd podczas kolejkowania plików moda dla mod ID {mod_id}: {e}")
- return 0
- return total_queued
- def remove_mod(self, mod_file_name, instance_dir):
- mod_path = Path(instance_dir) / "mods" / mod_file_name
- if mod_path.exists():
- try:
- mod_path.unlink()
- logging.info(f"Usunięto mod: {mod_path}")
- except Exception as e:
- logging.error(f"Błąd usuwania moda {mod_path}: {e}")
- raise IOError(f"Nie udało się usunąć moda: {e}")
- else:
- logging.warning(f"Mod {mod_file_name} nie istnieje w {instance_dir}/mods (pomijam usuwanie)")
- raise FileNotFoundError(f"Mod {mod_file_name} nie znaleziono w katalogu {instance_dir}/mods!")
- def _validate_and_queue_missing_files(self, instance_dir, version_id):
- """
- Sprawdza, czy wszystkie potrzebne pliki (client.jar, libraries, natives, assets) istnieją.
- Jeśli czegoś brakuje, dodaje brakujące pliki do kolejki pobierania.
- Zwraca liczbę brakujących plików dodanych do kolejki.
- """
- logging.info(f"Sprawdzanie brakujących plików dla wersji {version_id} w instancji {instance_dir}")
- queued_count = 0
- version_dir = Path(instance_dir) / "versions" / version_id
- version_json_path = version_dir / f"{version_id}.json"
- # Sprawdzenie version.json
- if not version_json_path.exists():
- logging.warning(f"Brak pliku {version_json_path}. Pobieram ponownie.")
- try:
- manifest = self.get_version_manifest()
- version_info = next((v for v in manifest["versions"] if v["id"] == version_id), None)
- if not version_info:
- raise ValueError(f"Wersja {version_id} nie istnieje w manifeście!")
- version_json_url = version_info["url"]
- self._download_metadata_sync(version_json_url, version_json_path, "version JSON")
- except Exception as e:
- logging.error(f"Błąd pobierania version.json dla {version_id}: {e}")
- raise ValueError(f"Nie udało się pobrać version.json: {e}")
- # Wczytanie version.json
- try:
- with version_json_path.open("r", encoding='utf-8') as f:
- version_data = json.load(f)
- except json.JSONDecodeError as e:
- logging.error(f"Błąd parsowania {version_json_path}: {e}")
- raise ValueError(f"Nieprawidłowy plik wersji JSON: {version_json_path}")
- # Sprawdzenie client.jar
- client_info = version_data.get("downloads", {}).get("client")
- if client_info:
- client_path = version_dir / f"{version_id}.jar"
- client_sha1 = client_info.get("sha1")
- if not client_path.exists() or (client_sha1 and not self._validate_sha1(str(client_path), client_sha1)):
- logging.warning(f"Brak lub uszkodzony client.jar: {client_path}. Dodaję do kolejki.")
- queued_count += self._queue_download(client_info["url"], client_path, "client JAR", client_sha1)
- else:
- logging.debug(f"client.jar istnieje i jest poprawny: {client_path}")
- # Sprawdzenie assetów
- asset_index_info = version_data.get("assetIndex")
- if asset_index_info:
- asset_index_id = asset_index_info["id"]
- asset_index_path = Path(ASSETS_DIR) / "indexes" / f"{asset_index_id}.json"
- asset_index_sha1 = asset_index_info.get("sha1")
- if not asset_index_path.exists() or (asset_index_sha1 and not self._validate_sha1(str(asset_index_path), asset_index_sha1)):
- logging.warning(f"Brak lub uszkodzony index assetów: {asset_index_path}. Pobieram ponownie.")
- self._download_metadata_sync(asset_index_info["url"], asset_index_path, "asset index")
- try:
- with asset_index_path.open("r", encoding='utf-8') as f:
- asset_data = json.load(f)
- except json.JSONDecodeError as e:
- logging.error(f"Błąd parsowania {asset_index_path}: {e}")
- raise ValueError(f"Nieprawidłowy plik indexu assetów: {asset_index_path}")
- for hash_path, info in asset_data.get("objects", {}).items():
- hash = info["hash"]
- obj_path = Path(ASSETS_DIR) / "objects" / hash[:2] / hash
- if not obj_path.exists() or not self._validate_sha1(str(obj_path), hash):
- obj_url = f"https://resources.download.minecraft.net/{hash[:2]}/{hash}"
- logging.debug(f"Brak lub uszkodzony asset: {obj_path}. Dodaję do kolejki.")
- queued_count += self._queue_download(obj_url, obj_path, "asset", hash)
- else:
- logging.debug(f"Asset istnieje i jest poprawny: {obj_path}")
- # Sprawdzenie bibliotek
- for lib in version_data.get("libraries", []):
- if self._is_library_applicable(lib):
- if "downloads" in lib and "artifact" in lib["downloads"]:
- artifact = lib["downloads"]["artifact"]
- lib_path = Path(LIBRARIES_DIR) / artifact["path"]
- lib_sha1 = artifact.get("sha1")
- if not lib_path.exists() or (lib_sha1 and not self._validate_sha1(str(lib_path), lib_sha1)):
- logging.debug(f"Brak lub uszkodzona biblioteka: {lib_path}. Dodaję do kolejki.")
- queued_count += self._queue_download(artifact["url"], lib_path, "library", lib_sha1)
- else:
- logging.debug(f"Biblioteka istnieje i jest poprawna: {lib_path}")
- # Sprawdzenie natives
- classifiers = lib["downloads"].get("classifiers", {})
- native_classifier_data = None
- current_os_key = sys.platform
- if current_os_key == "win32":
- current_os_key = "windows"
- elif current_os_key == "darwin":
- current_os_key = "macos"
- elif current_os_key.startswith("linux"):
- current_os_key = "linux"
- arch = '64' if sys.maxsize > 2**32 else '32'
- search_keys = [f"natives-{current_os_key}-{arch}", f"natives-{current_os_key}"]
- for key in search_keys:
- if key in classifiers:
- native_classifier_data = classifiers[key]
- logging.debug(f"Znaleziono klasyfikator natywny '{key}' dla biblioteki {lib.get('name', 'Nieznana')}")
- break
- if native_classifier_data:
- native_url = native_classifier_data["url"]
- native_sha1 = native_classifier_data.get("sha1")
- lib_name = lib.get("name", "unknown_lib").replace(":", "_").replace(".", "_")
- classifier_file_name = Path(native_classifier_data["path"]).name
- native_zip_path = version_dir / "natives_zips" / f"{lib_name}_{classifier_file_name}"
- if not native_zip_path.exists() or (native_sha1 and not self._validate_sha1(str(native_zip_path), native_sha1)):
- logging.debug(f"Brak lub uszkodzony plik natives: {native_zip_path}. Dodaję do kolejki.")
- queued_count += self._queue_download(native_url, native_zip_path, "native zip", native_sha1)
- else:
- logging.debug(f"Plik natives istnieje i jest poprawny: {native_zip_path}")
- logging.info(f"Zakończono sprawdzanie plików dla wersji {version_id}. Dodano do kolejki: {queued_count} plików.")
- return queued_count
- def _validate_sha1(self, file_path, expected_sha1):
- """
- Weryfikuje sumę SHA1 pliku. Zwraca True, jeśli plik istnieje i suma jest poprawna.
- """
- if not expected_sha1 or not re.match(r'^[a-f0-9]{40}$', expected_sha1):
- logging.warning(f"SHA1 validation skipped for {Path(file_path).name}: Invalid or missing SHA1 hash.")
- return Path(file_path).exists()
- try:
- sha1 = hashlib.sha1()
- with open(file_path, "rb") as f:
- for chunk in iter(lambda: f.read(8192), b""):
- sha1.update(chunk)
- calculated_sha1 = sha1.hexdigest()
- is_valid = calculated_sha1 == expected_sha1
- if not is_valid:
- logging.warning(f"SHA1 mismatch for {Path(file_path).name}. Expected: {expected_sha1}, Got: {calculated_sha1}")
- return is_valid
- except FileNotFoundError:
- logging.warning(f"SHA1 validation failed: file not found {file_path}")
- return False
- except Exception as e:
- logging.error(f"Error during SHA1 validation for {file_path}: {e}")
- return False
- def extract_natives(self, instance_dir, version_id):
- """
- Rozpakowuje pliki JAR z natives_zips do folderu natives dla danej instancji.
- """
- natives_zips_dir = Path(instance_dir) / "versions" / version_id / "natives_zips"
- natives_dir = Path(instance_dir) / "versions" / version_id / "natives"
- # Tworzenie folderu natives, jeśli nie istnieje
- natives_dir.mkdir(parents=True, exist_ok=True)
- logging.debug(f"Tworzenie folderu natives: {natives_dir}")
- # Sprawdzenie, czy folder natives_zips istnieje
- if not natives_zips_dir.exists():
- logging.warning(f"Folder natives_zips nie istnieje: {natives_zips_dir}. Brak natives do rozpakowania.")
- return
- # Pobieranie listy plików JAR w natives_zips
- jar_files = list(natives_zips_dir.glob("*.jar"))
- if not jar_files:
- logging.warning(f"Brak plików JAR w folderze natives_zips: {natives_zips_dir}")
- return
- # Rozpakowywanie każdego pliku JAR
- for jar_file in jar_files:
- logging.debug(f"Rozpakowywanie pliku JAR: {jar_file}")
- try:
- with zipfile.ZipFile(jar_file, "r") as zip_ref:
- # Pobieranie listy plików w JAR, pomijając META-INF
- file_list = [f for f in zip_ref.namelist() if not f.startswith("META-INF/")]
- for file_name in file_list:
- # Rozpakowywanie pliku do natives
- zip_ref.extract(file_name, natives_dir)
- logging.debug(f"Rozpakowano plik: {file_name} do {natives_dir}")
- except zipfile.BadZipFile:
- logging.error(f"Uszkodzony plik JAR: {jar_file}. Pomijanie.")
- continue
- except Exception as e:
- logging.error(f"Błąd podczas rozpakowywania pliku JAR {jar_file}: {e}")
- continue
- logging.info(f"Zakończono rozpakowywanie natives dla wersji {version_id} do {natives_dir}")
- def launch_game(self, instance_dir_path, username):
- import uuid
- from PyQt6.QtWidgets import QDialog
- instance_dir = Path(instance_dir_path)
- if not instance_dir.exists():
- raise FileNotFoundError(f"Katalog instancji nie istnieje: {instance_dir_path}")
- instance_settings_path = instance_dir / "settings.json"
- if not instance_settings_path.exists():
- raise FileNotFoundError(f"Plik ustawień instancji nie znaleziono: {instance_settings_path}")
- try:
- with instance_settings_path.open("r", encoding='utf-8') as f:
- instance_settings = json.load(f)
- except json.JSONDecodeError as e:
- logging.error(f"Błąd odczytu settings.json instancji {instance_dir}: {e}")
- raise ValueError(f"Nie udało się odczytać ustawień instancji: {e}")
- version_id = instance_settings.get("version")
- if not version_id:
- raise ValueError("Instancja nie ma przypisanej wersji gry. Uruchom instalację instancji ponownie.")
- # Sprawdzenie i pobieranie brakujących plików
- queued_count = self._validate_and_queue_missing_files(instance_dir, version_id)
- if queued_count > 0:
- logging.info(f"Znaleziono {queued_count} brakujących/uszkodzonych plików. Rozpoczynam pobieranie.")
- self.progress_dialog = DownloadProgressDialog(self, None)
- self.progress_dialog.set_total_files(queued_count)
- self.progress_dialog.cancel_signal.connect(self.cancel_downloads)
- # Automatyczne zamknięcie dialogu po zakończeniu pobierania
- def on_download_finished():
- if self.progress_dialog.successful_files_count == queued_count:
- logging.info(f"Pobieranie zakończone sukcesem: {queued_count}/{queued_count} plików. Zamykam dialog.")
- self.progress_dialog.accept() # Zamyka dialog z kodem 1 (Accepted)
- else:
- logging.warning(f"Pobieranie nie powiodło się: {self.progress_dialog.successful_files_count}/{queued_count} plików.")
- self.progress_dialog.reject() # Zamyka dialog z kodem 0 (Rejected)
- self.progress_dialog.download_process_finished.connect(on_download_finished)
- self.process_download_queue()
- # Wykonanie dialogu i sprawdzenie wyniku
- result = self.progress_dialog.exec()
- logging.debug(f"Dialog zamknięty z wynikiem: {result}")
- if result != 1 or self.progress_dialog.is_cancelled: # 1 to QDialog.Accepted
- logging.warning("Pobieranie zostało anulowane lub nie powiodło się.")
- raise RuntimeError("Pobieranie brakujących plików nie powiodło się lub zostało anulowane.")
- logging.debug("Dialog zamknięty pomyślnie, przechodzę do rozpakowania natives.")
- # Czyszczenie dialogu
- self.progress_dialog.deleteLater()
- self.progress_dialog = None
- # Rozpakowanie natives po upewnieniu się, że wszystkie pliki są na miejscu
- self.extract_natives(instance_dir, version_id)
- version_dir = instance_dir / "versions" / version_id
- version_json_path = version_dir / f"{version_id}.json"
- if not version_json_path.exists():
- raise FileNotFoundError(f"Plik wersji gry {version_json_path} nie istnieje. Uruchom instalację instancji ponownie.")
- try:
- with version_json_path.open("r", encoding='utf-8') as f:
- version_data = json.load(f)
- except json.JSONDecodeError as e:
- logging.error(f"Błąd odczytu JSON wersji {version_id}: {e}")
- raise ValueError(f"Nie udało się odczytać danych wersji: {e}")
- # --- Budowanie polecenia startowego ---
- # 1. Java path
- java_path = self.find_java_for_version(version_id)
- if not java_path or not Path(java_path).exists():
- raise FileNotFoundError(f"Nie znaleziono kompatybilnej Javy dla wersji {version_id} lub ścieżka Javy jest niepoprawna.")
- # 2. Classpath
- classpath = []
- modloader = instance_settings.get("modloader")
- launch_version_id = version_id
- if modloader:
- modded_version_jsons = list((instance_dir / "versions").glob(f"{version_id}-*.json"))
- if modded_version_jsons:
- modded_version_jsons.sort(key=lambda p: p.stat().st_mtime, reverse=True)
- found_modded_json_path = modded_version_jsons[0]
- launch_version_id = found_modded_json_path.stem
- logging.info(f"Znaleziono modowany JSON wersji: {found_modded_json_path.name} (ID: {launch_version_id})")
- try:
- with found_modded_json_path.open("r", encoding='utf-8') as f:
- version_data = json.load(f)
- except json.JSONDecodeError as e:
- logging.error(f"Błąd odczytu modowanego JSON {found_modded_json_path}: {e}")
- raise ValueError(f"Nie udało się odczytać danych modowanej wersji: {e}")
- main_jar_path = instance_dir / "versions" / launch_version_id / f"{launch_version_id}.jar"
- if main_jar_path.exists():
- classpath.append(str(main_jar_path))
- else:
- client_jar_candidate = list(version_dir.glob("*.jar"))
- if client_jar_candidate:
- main_jar_path = client_jar_candidate[0]
- classpath.append(str(main_jar_path))
- logging.warning(f"Główny JAR {launch_version_id}.jar nie istnieje, używam: {main_jar_path.name}")
- else:
- raise FileNotFoundError(f"Plik główny gry (.jar) nie istnieje: {main_jar_path}.")
- for lib in version_data.get("libraries", []):
- if self._is_library_applicable(lib):
- if "downloads" in lib and "artifact" in lib["downloads"]:
- # Dodajemy tylko biblioteki bez klasyfikatorów natywnych
- if not lib.get("downloads", {}).get("classifiers"):
- artifact = lib["downloads"]["artifact"]
- lib_path = Path(LIBRARIES_DIR) / artifact["path"]
- if lib_path.exists():
- classpath.append(str(lib_path))
- else:
- logging.warning(f"Brak pliku biblioteki: {lib_path}")
- elif "name" in lib:
- # Zgadywanie tylko dla bibliotek bez natywnych klasyfikatorów
- if not lib.get("natives"):
- parts = lib["name"].split(':')
- if len(parts) >= 3:
- group = parts[0].replace('.', '/')
- artifact = parts[1]
- version = parts[2]
- guessed_path = Path(LIBRARIES_DIR) / group / artifact / version / f"{artifact}-{version}.jar"
- if guessed_path.exists():
- classpath.append(str(guessed_path))
- else:
- logging.warning(f"Brak zgadywanej biblioteki: {guessed_path}")
- classpath_str = ";".join(classpath) if sys.platform == "win32" else ":".join(classpath)
- # 3. JVM arguments
- jvm_args = []
- ram = instance_settings.get("ram", self.settings.get("ram", "4G"))
- jvm_args.extend([f"-Xmx{ram}", "-Xms512M"])
- natives_dir = version_dir / "natives"
- if natives_dir.exists():
- jvm_args.append(f"-Djava.library.path={natives_dir}")
- else:
- logging.warning(f"Katalog natywnych bibliotek nie istnieje: {natives_dir}")
- jvm_args_extra = instance_settings.get("jvm_args_extra", self.settings.get("jvm_args", ""))
- if jvm_args_extra:
- jvm_args.extend(jvm_args_extra.split())
- # 4. Game arguments
- game_args = []
- main_class = version_data.get("mainClass", "net.minecraft.client.main.Main")
- # Generowanie UUID dla trybu offline
- offline_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, username))
- # Rozpoznanie typu wersji
- version_tuple, modern_args = parse_version_type(version_id)
- # Argumenty gry
- if modern_args:
- # Nowoczesne wersje (1.6+)
- game_args.extend([
- "--username", username,
- "--version", launch_version_id,
- "--gameDir", str(instance_dir),
- "--assetsDir", str(ASSETS_DIR),
- "--assetIndex", version_data.get("assetIndex", {}).get("id", "legacy"),
- "--uuid", offline_uuid,
- "--accessToken", "0",
- "--userType", "legacy",
- ])
- if version_tuple >= (1, 7, 0): # userProperties od 1.7.2
- game_args.extend(["--userProperties", "{}"])
- else:
- # Starsze wersje (1.5.2 i niżej)
- game_args.extend([
- username,
- "0" # sessionId
- ])
- # Rozdzielczość i pełny ekran
- resolution = instance_settings.get("resolution", self.settings.get("resolution", "1280x720"))
- if resolution and re.match(r"^\d+x\d+$", resolution):
- width, height = resolution.split('x')
- game_args.extend(["--width", width, "--height", height])
- if instance_settings.get("fullscreen", self.settings.get("fullscreen", False)):
- game_args.append("--fullscreen")
- # 5. Budowanie pełnego polecenia
- cmd = [java_path] + jvm_args + ["-cp", classpath_str, main_class] + game_args
- logging.info(f"Uruchamianie gry: {' '.join([str(c) for c in cmd])}")
- try:
- process = subprocess.Popen(
- cmd,
- cwd=str(instance_dir),
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True
- )
- stdout, stderr = process.communicate(timeout=300)
- logging.info(f"Gra stdout:\n{stdout}")
- if stderr:
- logging.error(f"Gra stderr:\n{stderr}")
- if process.returncode != 0:
- raise subprocess.CalledProcessError(process.returncode, cmd, stdout, stderr)
- logging.info(f"Gra uruchomiona pomyślnie (PID: {process.pid})")
- except subprocess.TimeoutExpired:
- logging.error("Uruchamianie gry przekroczyło limit czasu.")
- raise TimeoutError("Uruchamianie gry przekroczyło limit czasu.")
- except subprocess.CalledProcessError as e:
- logging.error(f"Błąd uruchamiania gry: Kod {e.returncode}, stderr: {e.stderr}")
- raise ValueError(f"Błąd uruchamiania gry: {e.stderr}")
- except Exception as e:
- logging.error(f"Nieoczekiwany błąd uruchamiania gry: {e}")
- raise RuntimeError(f"Nieoczekiwany błąd: {e}")
- def _is_library_applicable(self, library_data):
- rules = library_data.get('rules')
- if not rules:
- return True
- current_os_name = sys.platform
- if current_os_name == "win32":
- current_os_name = "windows"
- elif current_os_name == "darwin":
- current_os_name = "osx"
- for rule in rules:
- action = rule.get('action')
- os_info = rule.get('os', {})
- rule_os_name = os_info.get('name')
- rule_applies_to_current_os = False
- if rule_os_name is None:
- rule_applies_to_current_os = True
- elif rule_os_name == current_os_name:
- rule_applies_to_current_os = True
- if rule_applies_to_current_os:
- if action == 'disallow':
- logging.debug(f"Library rule disallowed: {library_data.get('name', 'Unknown')}")
- return False
- return True
- def _is_argument_applicable(self, arg_data):
- rules = arg_data.get('rules')
- if not rules:
- return True
- current_os_name = sys.platform
- if current_os_name == "win32":
- current_os_name = "windows"
- elif current_os_name == "darwin":
- current_os_name = "osx"
- disallows_rule_applies = False
- for rule in rules:
- action = rule.get('action')
- os_info = rule.get('os', {})
- rule_os_name = os_info.get('name')
- rule_applies_to_current_os = False
- if rule_os_name is None:
- rule_applies_to_current_os = True
- elif rule_os_name == current_os_name:
- rule_applies_to_current_os = True
- if rule_applies_to_current_os:
- if action == 'disallow':
- disallows_rule_applies = True
- break
- if disallows_rule_applies:
- return False
- return True
- def find_java(self):
- return self.java_versions[0][0] if self.java_versions else None
- def find_java_versions(self):
- java_versions = []
- checked_paths = set()
- try:
- java_path = "java"
- find_cmd = ["where", "java"] if sys.platform == "win32" else ["which", "java"]
- process = subprocess.run(find_cmd, capture_output=True, text=True, timeout=5, check=True)
- path_output = process.stdout.strip().splitlines()
- if path_output:
- resolved_path = path_output[0]
- if Path(resolved_path).is_file():
- java_path = resolved_path
- result = subprocess.run([java_path, "-version"], capture_output=True, text=True, timeout=5, check=True)
- version_line = result.stderr.splitlines()[0] if result.stderr else ""
- if java_path not in checked_paths:
- java_versions.append((java_path, f"System Java ({version_line.strip()})"))
- checked_paths.add(java_path)
- except (subprocess.CalledProcessError, FileNotFoundError, TimeoutError, Exception) as e:
- logging.debug(f"System 'java' not found or error: {e}")
- if sys.platform == "win32":
- program_files = Path(os.environ.get("ProgramFiles", "C:/Program Files"))
- program_files_x86 = Path(os.environ.get("ProgramFiles(x86)", "C:/Program Files (x86)"))
- java_install_dirs = [
- program_files / "Java",
- program_files_x86 / "Java",
- JAVA_DIR
- ]
- for base_dir in java_install_dirs:
- if not base_dir.exists():
- continue
- scan_dirs = [base_dir]
- try:
- for level1 in base_dir.iterdir():
- if level1.is_dir():
- scan_dirs.append(level1)
- try:
- for level2 in level1.iterdir():
- if level2.is_dir():
- scan_dirs.append(level2)
- except Exception as e:
- logging.debug(f"Error scanning subdir {level1}: {e}")
- except Exception as e:
- logging.debug(f"Error scanning base dir {base_dir}: {e}")
- for java_dir in scan_dirs:
- if java_dir.is_dir():
- java_exe = java_dir / "bin" / "java.exe"
- if java_exe.exists() and str(java_exe) not in checked_paths:
- try:
- result = subprocess.run([str(java_exe), "-version"], capture_output=True, text=True, timeout=5, check=True)
- version_line = result.stderr.splitlines()[0] if result.stderr else ""
- display_name = java_dir.relative_to(base_dir) if java_dir.is_relative_to(base_dir) else java_dir.name
- java_versions.append((str(java_exe), f"{display_name} ({version_line.strip()})"))
- checked_paths.add(str(java_exe))
- except (subprocess.CalledProcessError, FileNotFoundError, TimeoutError, Exception) as e:
- logging.debug(f"Error getting version for {java_exe}: {e}")
- elif sys.platform == "darwin":
- java_install_dirs = [
- Path("/Library/Java/JavaVirtualMachines"),
- Path("/usr/local/Cellar"),
- Path.home() / ".sdkman" / "candidates" / "java",
- JAVA_DIR
- ]
- for base_dir in java_install_dirs:
- if not base_dir.exists():
- continue
- try:
- for java_dir in base_dir.iterdir():
- if java_dir.is_dir():
- java_exe = java_dir / "Contents" / "Home" / "bin" / "java"
- if java_exe.exists() and str(java_exe) not in checked_paths:
- try:
- result = subprocess.run([str(java_exe), "-version"], capture_output=True, text=True, timeout=5, check=True)
- version_line = result.stderr.splitlines()[0] if result.stderr else ""
- display_name = java_dir.name
- java_versions.append((str(java_exe), f"{display_name} ({version_line.strip()})"))
- checked_paths.add(str(java_exe))
- except Exception as e:
- logging.debug(f"Error getting version for {java_exe}: {e}")
- except Exception as e:
- logging.debug(f"Error scanning base dir {base_dir}: {e}")
- elif sys.platform.startswith("linux"):
- java_install_dirs = [
- Path("/usr/lib/jvm"),
- Path("/opt/java"),
- Path.home() / ".sdkman" / "candidates" / "java",
- JAVA_DIR
- ]
- for base_dir in java_install_dirs:
- if not base_dir.exists():
- continue
- try:
- for java_dir in base_dir.iterdir():
- if java_dir.is_dir():
- java_exe = java_dir / "bin" / "java"
- if java_exe.exists() and str(java_exe) not in checked_paths:
- try:
- result = subprocess.run([str(java_exe), "-version"], capture_output=True, text=True, timeout=5, check=True)
- version_line = result.stderr.splitlines()[0] if result.stderr else ""
- display_name = java_dir.name
- java_versions.append((str(java_exe), f"{display_name} ({version_line.strip()})"))
- checked_paths.add(str(java_exe))
- except Exception as e:
- logging.debug(f"Error getting version for {java_exe}: {e}")
- except Exception as e:
- logging.debug(f"Error scanning base dir {base_dir}: {e}")
- logging.info(f"Znaleziono wersje Javy: {java_versions}")
- return java_versions
- def get_java_version_from_path(self, java_path):
- if not java_path or not Path(java_path).exists():
- return None
- try:
- result = subprocess.run([java_path, "-version"], capture_output=True, text=True, timeout=5, check=True)
- version_str = result.stderr.splitlines()[0] if result.stderr else ""
- match = re.search(r"(?:openjdk|java) version \"(\d+)(?:\.(\d+))?(?:\.(\d+))?", version_str)
- if match:
- major = int(match.group(1))
- if major == 1 and match.group(2) is not None:
- return int(match.group(2))
- return major
- match = re.search(r"openjdk (\d+)(?:\.(\d+))?", version_str)
- if match:
- return int(match.group(1))
- logging.warning(f"Nie można sparsować wersji Javy z: {version_str} dla {java_path}")
- return None
- except (subprocess.CalledProcessError, FileNotFoundError, TimeoutError, Exception) as e:
- logging.error(f"Błąd podczas odczytu wersji Javy z {java_path}: {e}")
- return None
- def get_required_java_version(self, version_id):
- logging.debug(f"Sprawdzam wymaganą wersję Javy dla {version_id}")
- try:
- manifest = self.get_version_manifest()
- version_info_from_manifest = next((v for v in manifest.get("versions", []) if v["id"] == version_id), None)
- if version_info_from_manifest:
- version_json_url = version_info_from_manifest.get("url")
- if version_json_url:
- response = requests.get(version_json_url, timeout=10)
- response.raise_for_status()
- version_data = response.json()
- required_java_from_json = version_data.get("javaVersion", {}).get("majorVersion")
- if required_java_from_json:
- logging.debug(f"Wymagana Java z Version JSON dla {version_id}: {required_java_from_json}")
- return required_java_from_json
- else:
- logging.debug(f"Version JSON dla {version_id} nie zawiera 'javaVersion'.")
- else:
- logging.warning(f"Manifest wersji dla {version_id} nie zawiera URL do version JSON.")
- else:
- logging.warning(f"Wersja {version_id} nie znaleziona w manifeście wersji.")
- except Exception as e:
- logging.debug(f"Nie udało się pobrać/sparsować version JSON dla {version_id} w celu sprawdzenia Javy: {e}. Używam domyślnej logiki.")
- try:
- parts = version_id.split('.')
- if re.match(r"^\d+w\d+[a-z]$", version_id):
- logging.debug(f"'{version_id}' to snapshot, szacuję wymaganą Javę.")
- try:
- year_week_match = re.match(r"^(\d+)w(\d+)", version_id)
- if year_week_match:
- year = int(year_week_match.group(1))
- week = int(year_week_match.group(2))
- if year >= 24:
- return 21
- elif year == 23 and week >= 14:
- return 17
- return 17
- except Exception as e:
- logging.warning(f"Błąd parsowania daty snapshota '{version_id}': {e}. Domyślnie Java 17.")
- return 17
- if len(parts) >= 2:
- major = int(parts[0])
- minor = int(parts[1])
- if major >= 2:
- return 21
- if major == 1:
- if minor >= 21:
- return 21
- elif minor == 20 and (len(parts) < 3 or int(parts[2]) >= 5):
- return 21
- elif minor >= 18:
- return 17
- elif minor >= 17:
- return 16
- elif minor >= 13:
- return 8
- else:
- return 8
- logging.warning(f"Nieobsługiwany format wersji gry '{version_id}' dla wymaganej Javy. Domyślnie Java 8.")
- return 8
- except Exception as e:
- logging.error(f"Nieoczekiwany błąd podczas określania wymaganej Javy dla '{version_id}': {e}. Domyślnie Java 8.")
- return 8
- def find_java_for_version(self, version_id):
- """
- Znajduje odpowiednią wersję Javy dla danej wersji Minecrafta.
- """
- # Mapowanie wymagań Javy
- java_requirements = {
- (1, 0): 8, # Wersje 1.0-1.16.5 -> Java 8
- (1, 17): 17, # Wersje 1.17-1.20 -> Java 17
- (1, 21): 21, # Wersje 1.21+ i snapshoty -> Java 21
- }
- # Rozpoznanie typu wersji
- version_tuple, _ = parse_version_type(version_id)
- required_java = 8 # Domyślnie Java 8
- # Snapshoty z 2025 zakładamy jako >= 1.21
- if "w" in version_id:
- required_java = 21
- else:
- for (major, minor), java_ver in java_requirements.items():
- if version_tuple >= (major, minor):
- required_java = java_ver
- logging.info(f"Wersja {version_id} wymaga Javy {required_java}+")
- # Szukanie Javy
- possible_java_paths = [
- shutil.which("java"),
- r"C:\Program Files\Java\jdk-{}\bin\java.exe".format(required_java),
- r"C:\Program Files\Java\jre-{}\bin\java.exe".format(required_java),
- r"C:\Program Files\AdoptOpenJDK\jdk-{}-hotspot\bin\java.exe".format(required_java),
- r"/usr/lib/jvm/java-{}-openjdk/bin/java".format(required_java),
- r"/usr/lib/jvm/java-{}-openjdk-amd64/bin/java".format(required_java),
- ]
- for path in possible_java_paths:
- if path and Path(path).exists():
- try:
- result = subprocess.run(
- [path, "-version"],
- capture_output=True,
- text=True,
- check=True
- )
- version_match = re.search(r'version "(\d+)(?:\.(\d+))?', result.stderr)
- if version_match:
- java_major = int(version_match.group(1))
- if java_major >= required_java:
- logging.info(f"Znaleziono kompatybilną Javę ({java_major}): {path}")
- return path
- else:
- logging.warning(f"Java {java_major} w {path} jest za stara, wymagana {required_java}")
- except (subprocess.CalledProcessError, FileNotFoundError):
- logging.warning(f"Ścieżka Javy {path} jest nieprawidłowa lub nie działa")
- # Fallback na dowolną Javę
- logging.warning(f"Nie znaleziono Javy {required_java}+, próbuję dowolnej wersji")
- for path in possible_java_paths:
- if path and Path(path).exists():
- logging.info(f"Używam fallback Javy: {path}")
- return path
- logging.error(f"Nie znaleziono żadnej wersji Javy dla wersji {version_id}")
- return None
- def create_instance(self, name, version_id, modloader=None, ram="4G", java_path_setting=None, jvm_args_extra="", base_instance_dir_input=None, parent_window=None):
- if not name:
- raise ValueError("Nazwa instancji nie może być pusta!")
- if not version_id:
- raise ValueError("Nie wybrano wersji Minecrafta!")
- if not re.match(r"^[a-zA-Z0-9._-]+$", version_id):
- raise ValueError(f"Nieprawidłowy format ID wersji: '{version_id}'.")
- if modloader and modloader.lower() not in ["forge", "neoforge", "fabric", "quilt"]:
- raise ValueError(f"Nieznany typ modloadera: '{modloader}'. Obsługiwane: Forge, NeoForge, Fabric, Quilt.")
- safe_name = re.sub(r'[<>:"/\\|?*]', '_', name)
- safe_name = safe_name.strip()
- if not safe_name:
- raise ValueError("Nazwa instancji po usunięciu nieprawidłowych znaków jest pusta.")
- if base_instance_dir_input and Path(base_instance_dir_input) != INSTANCES_DIR:
- instance_dir = Path(base_instance_dir_input)
- instance_dir = instance_dir / safe_name
- try:
- resolved_instance_dir = instance_dir.resolve()
- resolved_instances_dir = INSTANCES_DIR.resolve()
- if resolved_instances_dir in resolved_instance_dir.parents or resolved_instance_dir == resolved_instances_dir:
- raise ValueError(f"Docelowy katalog instancji '{instance_dir}' znajduje się wewnątrz domyślnego katalogu instancji '{INSTANCES_DIR}'. Wybierz katalog poza domyślnym lub użyj domyślnego sposobu nazewnictwa instancji.")
- except ValueError:
- raise
- except Exception as e:
- logging.error(f"Błąd walidacji ścieżki instancji: {e}")
- QMessageBox.critical(parent_window, "Błąd folderu instancji", f"Wystąpił błąd podczas walidacji ścieżki instancji: {e}")
- raise
- else:
- instance_dir = INSTANCES_DIR / safe_name
- if instance_dir.exists():
- is_empty = not any(instance_dir.iterdir())
- if not is_empty:
- raise FileExistsError(f"Katalog docelowy instancji '{instance_dir}' już istnieje i nie jest pusty! Wybierz inną nazwę lub folder.")
- instance_dir.mkdir(parents=True, exist_ok=True)
- if self.current_download_thread or self.download_queue:
- QMessageBox.warning(parent_window, "Pobieranie aktywne", "Inny proces pobierania jest aktywny. Spróbuj ponownie później.")
- return
- try:
- logging.info(f"Przygotowanie do pobierania plików dla instancji '{name}' ({version_id})")
- self.download_queue.clear()
- queued_version_files_count = self._queue_version_files(version_id, instance_dir)
- queued_modloader_files_count = 0
- if modloader:
- if not self.validate_modloader(modloader, version_id):
- raise ValueError(f"{modloader.capitalize()} prawdopodobnie nie wspiera wersji {version_id}. Wybierz inną wersję gry lub modloader.")
- queued_modloader_files_count = self._queue_modloader_installer(modloader, version_id, instance_dir)
- total_queued_for_download = len(self.download_queue)
- if total_queued_for_download == 0:
- logging.info("Wszystkie pliki do pobrania już istnieją lub nie wymagają pobierania. Przechodzę do konfiguracji.")
- self.extract_natives(version_id, instance_dir)
- if modloader:
- self._run_modloader_installer(modloader, version_id, instance_dir)
- self.save_instance_settings(instance_dir, name, version_id, modloader, ram, java_path_setting, jvm_args_extra)
- logging.info(f"Instancja '{name}' ({version_id}) stworzona pomyślnie w {instance_dir}")
- QMessageBox.information(parent_window, "Sukces", f"Instancja '{name}' stworzona pomyślnie!")
- if hasattr(parent_window, 'update_instance_tiles'):
- parent_window.update_instance_tiles()
- return str(instance_dir)
- logging.info(f"Kolejka pobierania gotowa. Plików do pobrania: {total_queued_for_download}")
- self.progress_dialog = DownloadProgressDialog(self, parent_window) # Pass launcher to dialog
- self.progress_dialog.set_total_files(total_queued_for_download)
- self.progress_dialog.cancel_signal.connect(self.cancel_downloads)
- self.progress_dialog.download_process_finished.connect(self._handle_create_instance_post_download)
- self._post_download_data = {
- "instance_dir": str(instance_dir),
- "name": name,
- "version_id": version_id,
- "modloader": modloader,
- "ram": ram,
- "java_path_setting": java_path_setting,
- "jvm_args_extra": jvm_args_extra,
- "parent_window": parent_window
- }
- self.process_download_queue()
- self.progress_dialog.exec()
- return str(instance_dir)
- except (ValueError, FileExistsError, FileNotFoundError, ConnectionError, PermissionError, Exception) as e:
- self.download_queue.clear()
- if self.current_download_thread:
- self.current_download_thread.cancel()
- self.current_download_thread.wait(2000)
- self.current_download_thread = None
- if self.progress_dialog:
- self.progress_dialog.reject()
- self.progress_dialog = None
- if instance_dir.exists():
- try:
- if not any(instance_dir.iterdir()):
- logging.debug(f"Usuwanie pustego katalogu instancji po błędzie: {instance_dir}")
- instance_dir.rmdir()
- else:
- logging.debug(f"Katalog instancji {instance_dir} nie jest pusty, nie usuwam go po błędzie.")
- except Exception as cleanup_e:
- logging.warning(f"Nie udało się posprzątać katalogu instancji {instance_dir} po błędzie: {cleanup_e}")
- logging.error(f"Błąd podczas przygotowania instancji: {e}")
- raise
- def _handle_create_instance_post_download(self, success):
- if self.progress_dialog:
- post_data = self._post_download_data
- QTimer.singleShot(0, self.progress_dialog.deleteLater)
- self.progress_dialog = None
- if post_data is None:
- logging.error("Brak danych do konfiguracji po pobraniu. Nie mogę zakończyć tworzenia instancji.")
- parent_window = QApplication.activeWindow()
- QMessageBox.critical(parent_window, "Błąd konfiguracji instancji", "Wystąpił wewnętrzny błąd po pobraniu. Spróbuj ponownie.")
- return
- instance_dir = Path(post_data["instance_dir"])
- name = post_data["name"]
- version_id = post_data["version_id"]
- modloader = post_data["modloader"]
- ram = post_data["ram"]
- java_path_setting = post_data["java_path_setting"]
- jvm_args_extra = post_data["jvm_args_extra"]
- parent_window = post_data.get("parent_window")
- self._post_download_data = None
- if not success:
- logging.warning("Tworzenie instancji anulowane lub zakończone z błędami pobierania.")
- QMessageBox.warning(parent_window, "Tworzenie instancji anulowane", "Tworzenie instancji zostało anulowane lub napotkało błędy podczas pobierania. Sprawdź logi.")
- return
- logging.info("Pobieranie dla instancji zakończone pomyślnie. Kontynuuję konfigurację...")
- try:
- self.extract_natives(version_id, instance_dir)
- if modloader:
- self._run_modloader_installer(modloader, version_id, instance_dir)
- self.save_instance_settings(instance_dir, name, version_id, modloader, ram, java_path_setting, jvm_args_extra)
- logging.info(f"Instancja '{name}' ({version_id}) stworzona pomyślnie w {instance_dir}")
- QMessageBox.information(parent_window, "Sukces", f"Instancja '{name}' stworzona pomyślnie!")
- if hasattr(parent_window, 'update_instance_tiles'):
- parent_window.update_instance_tiles()
- except (ValueError, FileNotFoundError, RuntimeError, TimeoutError, IOError, Exception) as e:
- logging.error(f"Błąd podczas konfiguracji instancji po pobraniu: {e}")
- QMessageBox.critical(parent_window, "Błąd konfiguracji instancji", f"Nie udało się zakończyć konfiguracji instancji: {e}")
- else:
- logging.error("Progress dialog finished signal received, but progress_dialog object was None.")
- def save_instance_settings(self, instance_dir, name, version_id, modloader, ram, java_path_setting, jvm_args_extra):
- instance_settings_path = instance_dir / "settings.json"
- settings_to_save = {
- "name": name,
- "version": version_id,
- "modloader": modloader,
- "ram": ram,
- "java_path": java_path_setting if java_path_setting is not None else "auto",
- "jvm_args": jvm_args_extra or DEFAULT_SETTINGS["jvm_args"],
- }
- if instance_settings_path.exists():
- try:
- with instance_settings_path.open("r", encoding='utf-8') as f:
- existing_settings = json.load(f)
- existing_settings.update(settings_to_save)
- settings_to_save = existing_settings
- except json.JSONDecodeError:
- logging.warning(f"Nieprawidłowy format settings.json w {instance_dir}, nadpisuję nowymi standardowymi polami.")
- except Exception as e:
- logging.warning(f"Błąd odczytu istniejącego settings.json w {instance_dir}: {e}, nadpisuję nowymi standardowymi polami.")
- try:
- with instance_settings_path.open("w", encoding='utf-8') as f:
- json.dump(settings_to_save, f, indent=4)
- logging.info(f"Zapisano ustawienia instancji do {instance_settings_path}")
- except Exception as e:
- logging.error(f"Błąd zapisu settings.json instancji {instance_dir}: {e}")
- raise IOError(f"Nie udało się zapisać ustawień instancji: {e}")
- def get_instance_list(self):
- valid_instances = []
- if not INSTANCES_DIR.exists():
- return []
- for item in INSTANCES_DIR.iterdir():
- settings_path = item / "settings.json"
- if item.is_dir() and settings_path.exists():
- instance_name = item.name
- instance_dir_path = str(item)
- try:
- with settings_path.open("r", encoding='utf-8') as f:
- settings = json.load(f)
- stored_name = settings.get("name")
- if stored_name and stored_name.strip():
- instance_name = stored_name.strip()
- except (json.JSONDecodeError, Exception) as e:
- logging.warning(f"Błąd odczytu nazwy instancji z {settings_path}: {e}. Używam nazwy folderu: {item.name}")
- valid_instances.append((instance_name, instance_dir_path))
- valid_instances.sort(key=lambda x: x[0].lower())
- return valid_instances
- def export_instance(self, instance_dir_path, zip_path):
- instance_dir = Path(instance_dir_path)
- zip_path = Path(zip_path)
- if not instance_dir.exists():
- raise FileNotFoundError(f"Katalog instancji nie istnieje: {instance_dir_path}")
- logging.info(f"Eksportowanie instancji z {instance_dir} do {zip_path}")
- zip_path.parent.mkdir(parents=True, exist_ok=True)
- try:
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
- for root, _, files in os.walk(instance_dir):
- for file in files:
- file_path = Path(root) / file
- archive_path = file_path.relative_to(instance_dir)
- if "natives_zips" not in archive_path.parts:
- zipf.write(file_path, archive_path)
- logging.info("Eksport zakończony pomyślnie.")
- except Exception as e:
- logging.error(f"Błąd eksportu instancji {instance_dir}: {e}")
- if zip_path.exists():
- try:
- zip_path.unlink()
- logging.warning(f"Usunięto częściowo utworzony plik zip: {zip_path}")
- except Exception as cleanup_e:
- logging.warning(f"Nie udało się usunąć częściowego pliku zip {zip_path}: {cleanup_e}")
- raise IOError(f"Nie udało się wyeksportować instancji: {e}")
- def import_instance(self, zip_path):
- zip_path = Path(zip_path)
- if not zip_path.exists():
- raise FileNotFoundError(f"Plik ZIP instancji nie istnieje: {zip_path}")
- if not zipfile.is_zipfile(zip_path):
- raise zipfile.BadZipFile(f"Plik '{zip_path.name}' nie jest prawidłowym plikiem ZIP.")
- INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
- try:
- instance_name_base = zip_path.stem
- safe_name_base = re.sub(r'[<>:"/\\|?*]', '_', instance_name_base)
- if not safe_name_base:
- safe_name_base = "imported_instance"
- instance_dir = INSTANCES_DIR / safe_name_base
- counter = 1
- while instance_dir.exists():
- instance_dir = INSTANCES_DIR / f"{safe_name_base}-{counter}"
- counter += 1
- logging.info(f"Importowanie instancji z {zip_path} do {instance_dir}")
- instance_dir.mkdir(parents=True)
- with zipfile.ZipFile(zip_path, "r") as zipf:
- zipf.extractall(instance_dir)
- settings_path = instance_dir / "settings.json"
- if settings_path.exists():
- try:
- with settings_path.open("r", encoding='utf-8') as f:
- settings = json.load(f)
- settings['name'] = instance_dir.name
- with settings_path.open("w", encoding='utf-8') as f:
- json.dump(settings, f, indent=4)
- logging.debug(f"Zaktualizowano nazwę w settings.json importowanej instancji do: {instance_dir.name}")
- except (json.JSONDecodeError, Exception) as e:
- logging.warning(f"Nie udało się zaktualizować nazwy w settings.json importowanej instancji {instance_dir.name}: {e}")
- logging.info("Import zakończony pomyślnie.")
- return str(instance_dir)
- except (zipfile.BadZipFile, FileNotFoundError, Exception) as e:
- logging.error(f"Błąd importu instancji z {zip_path}: {e}")
- if 'instance_dir' in locals() and instance_dir.exists():
- try:
- shutil.rmtree(instance_dir)
- logging.info(f"Usunięto częściowo zaimportowany katalog: {instance_dir}")
- except Exception as cleanup_e:
- logging.error(f"Błąd podczas czyszczenia katalogu {instance_dir}: {cleanup_e}")
- if isinstance(e, (zipfile.BadZipFile, FileNotFoundError)):
- raise e
- else:
- raise ValueError(f"Nie udało się zaimportować instancji: {e}")
- class CreateInstanceDialog(QDialog):
- def __init__(self, launcher, parent=None):
- super().__init__(parent)
- self.launcher = launcher
- self.setWindowTitle("Nowa instancja")
- self.setMinimumWidth(400)
- self.init_ui()
- self.version_combo.currentTextChanged.connect(self.update_modloaders)
- self.populate_versions()
- self.update_modloaders()
- def init_ui(self):
- layout = QVBoxLayout(self)
- layout.setSpacing(10)
- self.name_input = QLineEdit()
- self.name_input.setPlaceholderText("Nazwa instancji (np. MojaWersja)")
- layout.addWidget(QLabel("Nazwa instancji:"))
- layout.addWidget(self.name_input)
- instance_dir_layout = QHBoxLayout()
- self.instance_dir_input = QLineEdit(str(INSTANCES_DIR))
- self.instance_dir_input.setReadOnly(True)
- self.instance_dir_button = QPushButton("Wybierz inny folder docelowy...")
- self.instance_dir_button.clicked.connect(self.choose_instance_dir)
- self.use_custom_dir_check = QCheckBox("Użyj innego folderu")
- self.use_custom_dir_check.setChecked(False)
- self.use_custom_dir_check.stateChanged.connect(self.toggle_custom_dir_input)
- instance_dir_layout.addWidget(self.instance_dir_input)
- instance_dir_layout.addWidget(self.instance_dir_button)
- self.instance_dir_input.setEnabled(False)
- self.instance_dir_button.setEnabled(False)
- layout.addWidget(QLabel("Folder docelowy instancji:"))
- layout.addWidget(self.use_custom_dir_check)
- layout.addLayout(instance_dir_layout)
- self.version_combo = QComboBox()
- layout.addWidget(QLabel("Wersja Minecrafta:"))
- layout.addWidget(self.version_combo)
- self.modloader_combo = QComboBox()
- layout.addWidget(QLabel("Modloader (dla wybranych wersji):"))
- layout.addWidget(self.modloader_combo)
- advanced_group = QWidget()
- advanced_layout = QVBoxLayout(advanced_group)
- advanced_layout.setContentsMargins(0, 0, 0, 0)
- self.ram_input = QLineEdit(self.launcher.settings.get("ram", DEFAULT_SETTINGS["ram"]))
- advanced_layout.addWidget(QLabel("Maksymalna pamięć RAM (np. 4G, 2048M):"))
- advanced_layout.addWidget(self.ram_input)
- self.jvm_args_input = QLineEdit(self.launcher.settings.get("jvm_args", DEFAULT_SETTINGS["jvm_args"]))
- advanced_layout.addWidget(QLabel("Dodatkowe argumenty JVM:"))
- advanced_layout.addWidget(self.jvm_args_input)
- self.java_combo = QComboBox()
- self.java_combo.addItem("Automatyczny wybór", userData="auto")
- sorted_java_versions = sorted(self.launcher.java_versions, key=lambda x: self.launcher.get_java_version_from_path(x[0]) or 0, reverse=True)
- for java_path, version in sorted_java_versions:
- major_v = self.launcher.get_java_version_from_path(java_path)
- self.java_combo.addItem(f"{version} (Java {major_v}) - {java_path}", userData=java_path)
- default_java_setting = self.launcher.settings.get("java_path")
- if default_java_setting and default_java_setting.lower() != 'auto':
- default_index = self.java_combo.findData(default_java_setting)
- if default_index != -1:
- self.java_combo.setCurrentIndex(default_index)
- else:
- custom_item_text = f"Zapisana ścieżka: {default_java_setting} (Nieznana wersja)"
- self.java_combo.addItem(custom_item_text, userData=default_java_setting)
- self.java_combo.setCurrentIndex(self.java_combo.count() - 1)
- logging.warning(f"Zapisana ścieżka Javy w ustawieniach nie znaleziona wśród automatycznie wykrytych: {default_java_setting}. Dodano jako opcję niestandardową.")
- layout.addWidget(QLabel("Wersja Javy (zalecany 'Automatyczny wybór'):"))
- layout.addWidget(self.java_combo)
- layout.addWidget(advanced_group)
- button_layout = QHBoxLayout()
- create_button = QPushButton("Stwórz instancję")
- create_button.clicked.connect(self.check_and_accept)
- cancel_button = QPushButton("Anuluj")
- cancel_button.clicked.connect(self.reject)
- button_layout.addStretch(1)
- button_layout.addWidget(create_button)
- button_layout.addWidget(cancel_button)
- layout.addLayout(button_layout)
- def toggle_custom_dir_input(self, state):
- enabled = self.use_custom_dir_check.isChecked()
- self.instance_dir_input.setEnabled(enabled)
- self.instance_dir_button.setEnabled(enabled)
- if not enabled:
- self.instance_dir_input.setText(str(INSTANCES_DIR))
- def choose_instance_dir(self):
- current_dir = self.instance_dir_input.text()
- if not Path(current_dir).exists():
- current_dir = str(INSTANCES_DIR.parent)
- folder = QFileDialog.getExistingDirectory(self, "Wybierz folder docelowy dla instancji", current_dir)
- if folder:
- self.instance_dir_input.setText(folder)
- def populate_versions(self):
- self.version_combo.blockSignals(True)
- self.version_combo.clear()
- try:
- manifest = self.launcher.get_version_manifest()
- versions = sorted(manifest.get("versions", []), key=lambda x: x.get('releaseTime', '1970-01-01T00:00:00+00:00'), reverse=True)
- for version in versions:
- self.version_combo.addItem(version["id"])
- except ConnectionError as e:
- QMessageBox.critical(self.parentWidget(), "Błąd połączenia", f"Nie udało się pobrać listy wersji gry. Sprawdź połączenie z internetem.\n{e}")
- self.version_combo.addItem("Błąd pobierania listy wersji")
- self.version_combo.setEnabled(False)
- except Exception as e:
- logging.error(f"Nieoczekiwany błąd podczas pobierania listy wersji: {e}")
- QMessageBox.critical(self.parentWidget(), "Błąd", f"Nie udało się pobrać listy wersji gry: {e}")
- self.version_combo.addItem("Błąd ładowania listy wersji")
- self.version_combo.setEnabled(False)
- finally:
- self.version_combo.blockSignals(False)
- def update_modloaders(self):
- version_id = self.version_combo.currentText()
- self.modloader_combo.clear()
- self.modloader_combo.addItem("Brak")
- if not version_id or version_id.startswith("Błąd"):
- self.modloader_combo.setEnabled(False)
- return
- else:
- self.modloader_combo.setEnabled(True)
- supported_modloaders = []
- for modloader in ["forge", "neoforge", "fabric", "quilt"]:
- if self.launcher.validate_modloader(modloader, version_id):
- supported_modloaders.append(modloader.capitalize())
- if supported_modloaders:
- self.modloader_combo.addItems(supported_modloaders)
- elif re.match(r"^\d+w\d+[a-z]$", version_id):
- self.modloader_combo.addItem("Brak (Snapshot - brak oficjalnego wsparcia)")
- def check_and_accept(self):
- name = self.name_input.text().strip()
- if not name:
- QMessageBox.warning(self, "Brak nazwy", "Proszę podać nazwę instancji.")
- return
- version_id = self.version_combo.currentText()
- if not version_id or version_id.startswith("Błąd"):
- QMessageBox.warning(self, "Brak wersji", "Proszę wybrać poprawną wersję Minecrafta.")
- return
- ram_val = self.ram_input.text().strip().upper()
- if not re.match(r"^\d+[MG]$", ram_val):
- QMessageBox.warning(self, "Nieprawidłowy format RAM", "Proszę podać RAM w formacie np. '4G' lub '2048M'.")
- return
- selected_java_index = self.java_combo.currentIndex()
- if selected_java_index == -1:
- QMessageBox.warning(self, "Brak wyboru Javy", "Proszę wybrać wersję Javy lub 'Automatyczny wybór'.")
- return
- selected_java_path_data = self.java_combo.itemData(selected_java_index)
- selected_java_path = selected_java_path_data if selected_java_path_data is not None else "auto"
- if selected_java_path != 'auto':
- if not Path(selected_java_path).exists():
- QMessageBox.warning(self, "Nieprawidłowa ścieżka Javy", f"Wybrana ścieżka Javy nie istnieje:\n{selected_java_path}. Proszę wybrać inną lub 'Automatyczny wybór'.")
- return
- required_java = self.launcher.get_required_java_version(version_id)
- selected_java_major = self.launcher.get_java_version_from_path(selected_java_path)
- if selected_java_major is not None and selected_java_major < required_java:
- reply = QMessageBox.question(self, "Niekompatybilna Java?",
- f"Wybrana wersja Javy ({selected_java_major}) może nie być kompatybilna z wersją Minecrafta {version_id} (wymaga {required_java}+). Czy chcesz kontynuować?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
- if reply == QMessageBox.StandardButton.No:
- return
- if self.use_custom_dir_check.isChecked():
- chosen_base_dir_str = self.instance_dir_input.text().strip()
- if not chosen_base_dir_str:
- QMessageBox.warning(self, "Brak folderu docelowego", "Proszę wybrać folder docelowy dla instancji.")
- return
- self.accept()
- def get_data(self):
- selected_java_index = self.java_combo.currentIndex()
- selected_java_path_data = self.java_combo.itemData(selected_java_index)
- java_path_setting_to_save = selected_java_path_data if selected_java_path_data is not None else "auto"
- base_instance_dir_input_value = self.instance_dir_input.text().strip() if self.use_custom_dir_check.isChecked() else None
- if self.use_custom_dir_check.isChecked() and not base_instance_dir_input_value:
- base_instance_dir_input_value = None
- return {
- "name": self.name_input.text().strip(),
- "version": self.version_combo.currentText(),
- "modloader": self.modloader_combo.currentText().lower() if self.modloader_combo.currentText() != "Brak" and "snapshot" not in self.modloader_combo.currentText().lower() else None,
- "ram": self.ram_input.text().strip().upper(),
- "java_path_setting": java_path_setting_to_save,
- "jvm_args_extra": self.jvm_args_input.text().strip(),
- "base_instance_dir_input": base_instance_dir_input_value,
- }
- class ModBrowserDialog(QDialog):
- def __init__(self, launcher, version_id, instance_dir, parent=None):
- super().__init__(parent)
- self.launcher = launcher
- self.version_id = version_id
- self.instance_dir = instance_dir
- self.setWindowTitle(f"Przeglądarka modów dla {version_id}")
- self.setMinimumSize(800, 600)
- self.current_mod = None
- self.selected_compatible_file = None
- self.init_ui()
- self.mod_list.clear()
- self.reset_details()
- def init_ui(self):
- self.setStyleSheet(STYLESHEET)
- layout = QHBoxLayout(self)
- layout.setSpacing(10)
- left_panel = QWidget()
- left_layout = QVBoxLayout(left_panel)
- left_layout.setSpacing(5)
- self.search_input = QLineEdit()
- self.search_input.setPlaceholderText("Szukaj modów...")
- self.search_input.returnPressed.connect(self.search_mods)
- left_layout.addWidget(self.search_input)
- self.mod_list = QListWidget()
- self.mod_list.setIconSize(QSize(48, 48))
- self.mod_list.itemClicked.connect(self.show_mod_details)
- self.mod_list.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
- left_layout.addWidget(self.mod_list)
- layout.addWidget(left_panel, 1)
- right_panel_scroll = QScrollArea()
- right_panel_scroll.setWidgetResizable(True)
- right_panel_scroll.setMinimumWidth(300)
- right_panel_widget = QWidget()
- self.details_layout = QVBoxLayout(right_panel_widget)
- self.details_layout.setSpacing(10)
- self.details_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
- right_panel_scroll.setWidget(right_panel_widget)
- self.mod_icon = QLabel()
- self.mod_icon.setFixedSize(128, 128)
- self.mod_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.mod_icon.setStyleSheet("border: 1px solid #ccc; background-color: #e0e0e0;")
- self.details_layout.addWidget(self.mod_icon)
- self.mod_name = QLabel("Wybierz mod z listy")
- self.mod_name.setStyleSheet("font-size: 18px; font-weight: bold; margin-top: 5px;")
- self.mod_name.setWordWrap(True)
- self.details_layout.addWidget(self.mod_name)
- self.mod_author = QLabel("Autor: Brak")
- self.details_layout.addWidget(self.mod_author)
- self.mod_downloads = QLabel("Pobrania: Brak danych")
- self.details_layout.addWidget(self.mod_downloads)
- self.mod_date = QLabel("Aktualizacja: Brak danych")
- self.details_layout.addWidget(self.mod_date)
- self.mod_version = QLabel("Kompatybilna wersja pliku: Szukam...")
- self.details_layout.addWidget(self.mod_version)
- self.mod_description_label = QLabel("Opis:")
- self.details_layout.addWidget(self.mod_description_label)
- self.mod_description = QTextEdit()
- self.mod_description.setReadOnly(True)
- self.mod_description.setMinimumHeight(150)
- self.mod_description.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
- self.details_layout.addWidget(self.mod_description)
- self.dependency_check = QCheckBox("Pobierz wymagane mody (zalecane)")
- self.dependency_check.setChecked(True)
- self.details_layout.addWidget(self.dependency_check)
- self.details_layout.addStretch(1)
- button_layout = QHBoxLayout()
- self.install_button = QPushButton("Zainstaluj")
- self.install_button.clicked.connect(self.install_mod)
- self.install_button.setEnabled(False)
- self.remove_button = QPushButton("Usuń")
- self.remove_button.setProperty("deleteButton", "true")
- self.remove_button.clicked.connect(self.remove_mod)
- self.remove_button.setEnabled(False)
- self.remove_button.setStyleSheet("background-color: #f44336;")
- self.remove_button.setStyleSheet(self.remove_button.styleSheet() + """
- QPushButton:hover { background-color: #d32f2f; }
- QPushButton:disabled { background-color: #cccccc; }
- """)
- button_layout.addWidget(self.install_button)
- button_layout.addWidget(self.remove_button)
- self.details_layout.addLayout(button_layout)
- close_button = QPushButton("Zamknij")
- close_button.clicked.connect(self.accept)
- self.details_layout.addWidget(close_button)
- layout.addWidget(right_panel_scroll, 2)
- def search_mods(self):
- query = self.search_input.text().strip()
- if not query:
- self.mod_list.clear()
- self.reset_details()
- self.mod_name.setText("Wprowadź frazę do wyszukiwania.")
- return
- logging.info(f"Wyszukiwanie modów: '{query}' dla wersji {self.version_id}")
- self.mod_list.clear()
- self.reset_details()
- self.mod_name.setText("Szukam modów...")
- self.setCursor(Qt.CursorShape.WaitCursor)
- try:
- mods = self.launcher.get_curseforge_mods(query, self.version_id)
- self.unsetCursor()
- if not mods:
- self.mod_name.setText("Brak wyników.")
- return
- self.mod_name.setText("Wybierz mod z listy")
- for mod in mods:
- compatible_file = None
- files = mod.get("latestFiles", [])
- files.sort(key=lambda x: x.get('fileDate', '1970-01-01T00:00:00Z'), reverse=True)
- for file in files:
- if self.version_id in file.get("gameVersions", []):
- compatible_file = file
- break
- item_text = f"{mod.get('name', 'Nazwa nieznana')}"
- if not compatible_file:
- item_text += " (Brak wersji dla tej gry)"
- list_item = QListWidgetItem(item_text)
- item_data = {
- 'mod': mod,
- 'compatible_file': compatible_file
- }
- list_item.setData(Qt.ItemDataRole.UserRole, item_data)
- icon_url = mod.get("logo", {}).get("url")
- if icon_url:
- icon_file_extension = Path(icon_url).suffix or ".png"
- icon_dest_path = MOD_ICONS_DIR / f"{mod['id']}{icon_file_extension}"
- if icon_dest_path.exists():
- list_item.setIcon(QIcon(str(icon_dest_path)))
- else:
- pass
- self.mod_list.addItem(list_item)
- except (requests.exceptions.RequestException, PermissionError) as e:
- self.unsetCursor()
- QMessageBox.critical(self, "Błąd API CurseForge", f"Wystąpił błąd podczas wyszukiwania modów:\n{e}")
- logging.error(f"Błąd wyszukiwania modów: {e}")
- self.mod_name.setText("Błąd API CurseForge.")
- except Exception as e:
- self.unsetCursor()
- logging.error(f"Nieoczekiwany błąd podczas wyszukiwania modów: {e}")
- QMessageBox.critical(self, "Błąd wyszukiwania modów", f"Nie udało się wyszukać modów: {e}")
- self.mod_name.setText("Błąd wyszukiwania.")
- def show_mod_details(self, item):
- item_data = item.data(Qt.ItemDataRole.UserRole)
- mod = item_data.get('mod')
- compatible_file = item_data.get('compatible_file')
- if not mod:
- self.reset_details()
- return
- self.current_mod = mod
- self.selected_compatible_file = compatible_file
- self.mod_name.setText(mod.get("name", "Nazwa nieznana"))
- authors = mod.get("authors", [])
- self.mod_author.setText(f"Autor: {authors[0].get('name', 'Brak danych') if authors else 'Brak danych'}")
- self.mod_downloads.setText(f"Pobrania: {mod.get('downloadCount', 'Brak danych')}")
- try:
- date_modified_ts = mod.get('dateModified')
- if date_modified_ts is not None:
- date_modified = datetime.fromtimestamp(date_modified_ts / 1000).strftime('%Y-%m-%d %H:%M')
- self.mod_date.setText(f"Aktualizacja: {date_modified}")
- else:
- self.mod_date.setText("Aktualizacja: Brak danych")
- except Exception as e:
- logging.warning(f"Błąd parsowania daty modyfikacji dla mod ID {mod.get('id')}: {e}")
- self.mod_date.setText("Aktualizacja: Nieprawidłowa data")
- if compatible_file:
- self.mod_version.setText(f"Kompatybilny plik: {compatible_file.get('fileName', 'Brak danych')}")
- self.install_button.setEnabled(True)
- mod_file_name = compatible_file.get("fileName")
- if mod_file_name:
- mod_path = Path(self.instance_dir) / "mods" / mod_file_name
- self.remove_button.setEnabled(mod_path.exists())
- else:
- self.remove_button.setEnabled(False)
- else:
- self.mod_version.setText("Kompatybilny plik: Brak dla tej wersji")
- self.install_button.setEnabled(False)
- self.remove_button.setEnabled(False)
- description_text = mod.get("summary", "")
- self.mod_description.setHtml(description_text or "Brak opisu.")
- self.mod_icon.clear()
- icon_url = mod.get("logo", {}).get("url")
- if icon_url:
- icon_file_extension = Path(icon_url).suffix or ".png"
- icon_dest_path = MOD_ICONS_DIR / f"{mod['id']}{icon_file_extension}"
- if icon_dest_path.exists():
- try:
- pixmap = QPixmap(str(icon_dest_path)).scaled(128, 128, Qt.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
- self.mod_icon.setPixmap(pixmap)
- except Exception as e:
- logging.warning(f"Błąd ładowania ikony z pliku {icon_dest_path}: {e}")
- self.mod_icon.setText("Błąd ikony")
- else:
- self.mod_icon.setText("Ładowanie ikony...")
- else:
- self.mod_icon.setText("Brak ikony")
- def reset_details(self):
- self.current_mod = None
- self.selected_compatible_file = None
- self.mod_icon.clear()
- self.mod_icon.setText("Ikona")
- self.mod_name.setText("Wybierz mod z listy")
- self.mod_author.setText("Autor: Brak")
- self.mod_downloads.setText("Pobrania: Brak danych")
- self.mod_date.setText("Aktualizacja: Brak danych")
- self.mod_version.setText("Kompatybilny plik: Brak danych")
- self.mod_description.setHtml("Wybierz mod z listy, aby zobaczyć szczegóły.")
- self.install_button.setEnabled(False)
- self.remove_button.setEnabled(False)
- self.dependency_check.setChecked(True)
- def install_mod(self):
- if not self.current_mod or not self.selected_compatible_file:
- QMessageBox.warning(self, "Błąd", "Proszę wybrać mod do instalacji i upewnić się, że jest dostępna kompatybilna wersja pliku.")
- return
- mod_id = self.current_mod.get("id")
- mod_name_display = self.current_mod.get("name", "Wybrany mod")
- download_deps = self.dependency_check.isChecked()
- reply = QMessageBox.question(self, "Potwierdzenie instalacji",
- f"Zainstalować mod '{mod_name_display}' (dla wersji {self.version_id})?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
- if reply == QMessageBox.StandardButton.No:
- return
- if self.launcher.current_download_thread or self.launcher.download_queue:
- QMessageBox.warning(self, "Pobieranie aktywne", "Inny proces pobierania jest aktywny. Poczekaj na jego zakończenie lub anuluj.")
- return
- try:
- logging.info(f"Rozpoczęcie instalacji moda '{mod_name_display}' (ID: {mod_id}) dla wersji {self.version_id}")
- self.launcher.download_queue.clear()
- visited_mods_during_install = set()
- total_queued = self.launcher._queue_curseforge_mod_files(
- mod_id, self.version_id, self.instance_dir,
- download_dependencies=download_deps,
- visited_mods=visited_mods_during_install
- )
- if total_queued == 0:
- QMessageBox.information(self, "Informacja", f"Brak plików do pobrania dla moda '{mod_name_display}' (ID: {mod_id}). Pliki mogą już istnieć lub brak kompatybilnej wersji.")
- logging.warning("Install mod: No files queued.")
- self.show_mod_details(self.mod_list.currentItem())
- return
- logging.info(f"Kolejka pobierania modów gotowa. Plików do pobrania: {total_queued}")
- self.launcher.progress_dialog = DownloadProgressDialog(self.launcher, self) # Pass launcher to dialog
- self.launcher.progress_dialog.set_total_files(total_queued)
- self.launcher.progress_dialog.cancel_signal.connect(self.launcher.cancel_downloads)
- self.launcher.progress_dialog.download_process_finished.connect(self._handle_mod_install_post_download)
- self._post_mod_install_data = {
- "mod_name": mod_name_display,
- "mod_id": mod_id,
- "parent_dialog": self
- }
- self.launcher.process_download_queue()
- self.launcher.progress_dialog.exec()
- except (ValueError, requests.exceptions.RequestException, PermissionError, Exception) as e:
- logging.error(f"Błąd podczas przygotowania instalacji moda '{mod_name_display}': {e}")
- QMessageBox.critical(self, "Błąd instalacji moda", f"Nie udało się przygotować instalacji moda:\n{e}")
- self.launcher.download_queue.clear()
- def _handle_mod_install_post_download(self, success):
- if self.launcher.progress_dialog:
- post_data = self._post_mod_install_data
- QTimer.singleShot(0, self.launcher.progress_dialog.deleteLater)
- self.launcher.progress_dialog = None
- if post_data is None:
- logging.error("Brak danych do konfiguracji po pobraniu moda. Nie mogę zakończyć instalacji.")
- QMessageBox.critical(self, "Błąd instalacji moda", "Wystąpił wewnętrzny błąd po pobraniu moda. Spróbuj ponownie.")
- return
- mod_name = post_data.get("mod_name", "Mod")
- mod_id = post_data.get("mod_id")
- parent_dialog = post_data.get("parent_dialog")
- self._post_mod_install_data = None
- if success:
- logging.info(f"Mod '{mod_name}' zainstalowany pomyślnie.")
- QMessageBox.information(parent_dialog, "Sukces", f"Mod '{mod_name}' zainstalowany pomyślnie!")
- if mod_id is not None:
- for i in range(self.mod_list.count()):
- item = self.mod_list.item(i)
- item_data = item.data(Qt.ItemDataRole.UserRole)
- if item_data and item_data.get('mod', {}).get('id') == mod_id:
- self.mod_list.setCurrentItem(item)
- self.show_mod_details(item)
- break
- else:
- logging.warning(f"Instalacja moda '{mod_name}' anulowana lub zakończona z błędami.")
- QMessageBox.warning(parent_dialog, "Instalacja anulowana", f"Instalacja moda '{mod_name}' została anulowana lub napotkała błędy.")
- def remove_mod(self):
- if not self.current_mod or not self.selected_compatible_file:
- QMessageBox.warning(self, "Błąd", "Proszę wybrać mod do usunięcia.")
- return
- mod_name_display = self.current_mod.get("name", "Wybrany mod")
- mod_file_name = self.selected_compatible_file.get("fileName")
- if not mod_file_name:
- QMessageBox.warning(self, "Błąd", "Nie można określić nazwy pliku moda do usunięcia.")
- return
- mod_path = Path(self.instance_dir) / "mods" / mod_file_name
- if not mod_path.exists():
- QMessageBox.warning(self, "Błąd usuwania", f"Plik moda '{mod_file_name}' nie znaleziono w katalogu instancji.\nMożliwe, że został już usunięty lub instalacja nie była kompletna.")
- self.remove_button.setEnabled(False)
- return
- reply = QMessageBox.question(self, "Potwierdzenie usunięcia",
- f"Usunąć mod '{mod_name_display}' ({mod_file_name})?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
- if reply == QMessageBox.StandardButton.No:
- return
- try:
- self.launcher.remove_mod(mod_file_name, self.instance_dir)
- QMessageBox.information(self, "Sukces", f"Usunięto mod: {mod_file_name}")
- self.remove_button.setEnabled(False)
- except FileNotFoundError:
- QMessageBox.warning(self, "Błąd usuwania", f"Plik moda nie znaleziono w katalogu: {mod_file_name}")
- self.remove_button.setEnabled(False)
- except IOError as e:
- QMessageBox.critical(self, "Błąd usuwania", f"Wystąpił błąd podczas usuwania pliku:\n{e}")
- except Exception as e:
- logging.error(f"Nieoczekiwany błąd podczas usuwania moda {mod_file_name}: {e}")
- QMessageBox.critical(self, "Błąd usuwania", f"Wystąpił nieoczekiwany błąd podczas usuwania moda:\n{e}")
- class EditInstanceDialog(QDialog):
- def __init__(self, instance_dir, parent=None):
- super().__init__(parent)
- self.instance_dir = Path(instance_dir)
- self.setWindowTitle("Edytuj instancję")
- self.setFixedSize(400, 300)
- self.init_ui()
- self.load_settings()
- def init_ui(self):
- layout = QVBoxLayout()
- # RAM
- ram_label = QLabel("Maksymalna pamięć RAM:")
- self.ram_input = QComboBox()
- self.ram_input.addItems(["2G", "4G", "6G", "8G", "12G", "16G"])
- layout.addWidget(ram_label)
- layout.addWidget(self.ram_input)
- # Java path
- java_label = QLabel("Ścieżka do Javy (puste = automatyczne):")
- self.java_input = QLineEdit()
- java_browse = QPushButton("Przeglądaj")
- java_browse.clicked.connect(self.browse_java)
- java_layout = QHBoxLayout()
- java_layout.addWidget(self.java_input)
- java_layout.addWidget(java_browse)
- layout.addWidget(java_label)
- layout.addLayout(java_layout)
- # Rozdzielczość
- resolution_label = QLabel("Rozdzielczość (np. 1280x720):")
- self.resolution_input = QLineEdit()
- layout.addWidget(resolution_label)
- layout.addWidget(self.resolution_input)
- # Pełny ekran
- self.fullscreen_checkbox = QCheckBox("Pełny ekran")
- layout.addWidget(self.fullscreen_checkbox)
- # Przyciski
- buttons = QHBoxLayout()
- save_button = QPushButton("Zapisz")
- save_button.clicked.connect(self.save_settings)
- cancel_button = QPushButton("Anuluj")
- cancel_button.clicked.connect(self.reject)
- buttons.addWidget(save_button)
- buttons.addWidget(cancel_button)
- layout.addLayout(buttons)
- self.setLayout(layout)
- def browse_java(self):
- java_path, _ = QFileDialog.getOpenFileName(self, "Wybierz plik java.exe", "", "Pliki wykonywalne (*.exe);;Wszystkie pliki (*.*)")
- if java_path:
- self.java_input.setText(java_path)
- def load_settings(self):
- settings_path = self.instance_dir / "settings.json"
- if settings_path.exists():
- try:
- with settings_path.open("r", encoding='utf-8') as f:
- settings = json.load(f)
- self.ram_input.setCurrentText(settings.get("ram", "4G"))
- self.java_input.setText(settings.get("java_path", ""))
- self.resolution_input.setText(settings.get("resolution", "1280x720"))
- self.fullscreen_checkbox.setChecked(settings.get("fullscreen", False))
- except Exception as e:
- logging.error(f"Błąd ładowania ustawień instancji {settings_path}: {e}")
- def save_settings(self):
- settings = {
- "version": self.load_settings_version(),
- "ram": self.ram_input.currentText(),
- "java_path": self.java_input.text(),
- "resolution": self.resolution_input.text(),
- "fullscreen": self.fullscreen_checkbox.isChecked(),
- }
- settings_path = self.instance_dir / "settings.json"
- try:
- with settings_path.open("w", encoding='utf-8') as f:
- json.dump(settings, f, indent=4)
- logging.info(f"Zapisano ustawienia instancji: {settings_path}")
- self.accept()
- except Exception as e:
- logging.error(f"Błąd zapisu ustawień instancji {settings_path}: {e}")
- QMessageBox.critical(self, "Błąd", f"Nie udało się zapisać ustawień: {e}")
- def load_settings_version(self):
- settings_path = self.instance_dir / "settings.json"
- if settings_path.exists():
- try:
- with settings_path.open("r", encoding='utf-8') as f:
- settings = json.load(f)
- return settings.get("version", "")
- except:
- return ""
- return ""
- class LauncherWindow(QMainWindow):
- def __init__(self):
- super().__init__()
- self.launcher = MinecraftLauncher()
- self.setWindowTitle("Paffcio's Minecraft Launcher")
- self.setGeometry(100, 100, 900, 650)
- self.selected_instance_dir = None
- self.init_ui()
- self.apply_theme()
- self.update_instance_tiles()
- def update_buttons_state(self):
- """
- Aktualizuje stan przycisków w zależności od wybranej instancji.
- """
- has_selection = bool(self.instance_list.selectedItems())
- has_valid_settings = False
- version_id = None
- if has_selection:
- current_item = self.instance_list.currentItem()
- instance_dir_path = current_item.data(Qt.ItemDataRole.UserRole)
- settings_path = Path(instance_dir_path) / "settings.json"
- if settings_path.exists():
- try:
- with settings_path.open("r", encoding='utf-8') as f:
- settings = json.load(f)
- version_id = settings.get("version")
- has_valid_settings = bool(version_id)
- except Exception as e:
- logging.error(f"Błąd odczytu settings.json dla instancji {instance_dir_path}: {e}")
- self.play_button.setEnabled(has_selection and has_valid_settings)
- self.edit_instance_button.setEnabled(has_selection)
- self.mod_browser_button.setEnabled(has_selection and has_valid_settings)
- self.delete_instance_button.setEnabled(has_selection)
- logging.debug(f"Zaktualizowano stan przycisków: play={self.play_button.isEnabled()}, edit={self.edit_instance_button.isEnabled()}, mod_browser={self.mod_browser_button.isEnabled()}, delete={self.delete_instance_button.isEnabled()}")
- def init_ui(self):
- # Główny widget i layout
- main_widget = QWidget()
- self.setCentralWidget(main_widget)
- main_layout = QVBoxLayout(main_widget)
- main_layout.setContentsMargins(10, 10, 10, 10)
- main_layout.setSpacing(10)
- logging.debug("Inicjalizacja głównego layoutu")
- # Layout na sidebar i główną zawartość
- content_layout = QHBoxLayout()
- content_layout.setSpacing(10)
- # Sidebar
- sidebar = QWidget()
- sidebar.setMinimumWidth(200)
- sidebar.setMaximumWidth(300)
- sidebar_layout = QVBoxLayout(sidebar)
- sidebar_layout.setContentsMargins(0, 0, 0, 0)
- sidebar_layout.setSpacing(5)
- logging.debug("Inicjalizacja sidebara")
- sidebar_layout.addWidget(QLabel("Twoje instancje:"))
- self.instance_list = QListWidget()
- self.instance_list.itemSelectionChanged.connect(self.handle_instance_selection_change)
- self.instance_list.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
- sidebar_layout.addWidget(self.instance_list)
- logging.debug("Dodano listę instancji")
- # Przyciski akcji instancji
- instance_actions_layout = QVBoxLayout()
- instance_actions_layout.setSpacing(5)
- self.play_button = QPushButton("Graj")
- self.play_button.clicked.connect(self.play_instance)
- self.play_button.setEnabled(False)
- instance_actions_layout.addWidget(self.play_button)
- logging.debug("Dodano przycisk Graj")
- self.edit_instance_button = QPushButton("Edytuj instancję")
- self.edit_instance_button.clicked.connect(self.edit_instance)
- self.edit_instance_button.setEnabled(False)
- self.edit_instance_button.setStyleSheet("background-color: #2196F3; color: white;") # Tymczasowy styl dla widoczności
- instance_actions_layout.addWidget(self.edit_instance_button)
- logging.debug("Dodano przycisk Edytuj instancję")
- self.mod_browser_button = QPushButton("Przeglądaj mody")
- self.mod_browser_button.clicked.connect(self.open_mod_browser)
- self.mod_browser_button.setEnabled(False)
- instance_actions_layout.addWidget(self.mod_browser_button)
- logging.debug("Dodano przycisk Przeglądaj mody")
- self.delete_instance_button = QPushButton("Usuń instancję")
- self.delete_instance_button.setProperty("deleteButton", "true")
- self.delete_instance_button.clicked.connect(self.delete_instance)
- self.delete_instance_button.setEnabled(False)
- instance_actions_layout.addWidget(self.delete_instance_button)
- logging.debug("Dodano przycisk Usuń instancję")
- sidebar_layout.addLayout(instance_actions_layout)
- content_layout.addWidget(sidebar, 1)
- logging.debug("Dodano sidebar do content_layout")
- # Główna zawartość
- main_content_area = QWidget()
- main_content_layout = QVBoxLayout(main_content_area)
- main_content_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
- main_content_layout.addStretch(1)
- self.main_info_label = QLabel("Wybierz instancję z listy po lewej lub stwórz nową instancję (Plik -> Nowa instancja...).")
- self.main_info_label.setWordWrap(True)
- self.main_info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- main_content_layout.addWidget(self.main_info_label)
- main_content_layout.addStretch(2)
- content_layout.addWidget(main_content_area, 3)
- main_layout.addLayout(content_layout)
- logging.debug("Dodano główną zawartość")
- # Status bar
- self.status_label = QLabel("Gotowy.")
- self.status_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
- main_layout.addWidget(self.status_label)
- logging.debug("Dodano status bar")
- # Menu
- menubar = self.menuBar()
- file_menu = menubar.addMenu("Plik")
- file_menu.addAction("Nowa instancja...", self.create_instance)
- file_menu.addSeparator()
- file_menu.addAction("Importuj instancję...", self.import_instance)
- file_menu.addAction("Eksportuj wybraną instancję...", self.export_instance)
- file_menu.addSeparator()
- file_menu.addAction("Zamknij", self.close)
- settings_menu = menubar.addMenu("Ustawienia")
- settings_menu.addAction("Ustawienia launchera...", self.open_settings)
- accounts_menu = menubar.addMenu("Konta")
- accounts_menu.addAction("Ustaw nazwę konta offline...", self.set_offline_account)
- logging.debug("Dodano menu")
- def edit_instance(self):
- """
- Otwiera okno edycji wybranej instancji.
- """
- selected_items = self.instance_list.selectedItems()
- if not selected_items:
- logging.warning("Próba edycji instancji bez wybrania instancji.")
- return
- instance_name = selected_items[0].text()
- instance_dir = Path(self.launcher.instances_dir) / instance_name
- dialog = EditInstanceDialog(instance_dir, self)
- dialog.exec_()
- logging.info(f"Otwarto okno edycji dla instancji: {instance_name}")
- def apply_theme(self):
- theme = self.launcher.settings.get("theme", "Light")
- if theme == "Light":
- self.setStyleSheet(STYLESHEET)
- else:
- dark_stylesheet = STYLESHEET + """
- QMainWindow, QDialog, QWidget {
- background-color: #2e2e2e;
- color: #cccccc;
- }
- QLabel {
- color: #cccccc;
- }
- QListWidget {
- background-color: #3a3a3a;
- color: #cccccc;
- border: 1px solid #555555;
- }
- QListWidget::item:selected {
- background-color: #5a5a5a;
- color: #ffffff;
- }
- QLineEdit, QComboBox, QTextEdit {
- background-color: #4a4a4a;
- color: #cccccc;
- border: 1px solid #666666;
- }
- QTextEdit {
- background-color: #3a3a3a;
- border: 1px solid #555555;
- }
- QPushButton {
- background-color: #4CAF50;
- color: white;
- }
- QPushButton:hover {
- background-color: #45a049;
- }
- QPushButton:disabled {
- background-color: #555555;
- color: #aaaaaa;
- }
- QPushButton[deleteButton="true"] {
- background-color: #c62828;
- }
- QPushButton[deleteButton="true"]:hover {
- background-color: #d32f2f;
- }
- QPushButton[deleteButton="true"]:disabled {
- background-color: #555555;
- }
- QProgressBar {
- background-color: #555555;
- border: 1px solid #666666;
- }
- QProgressBar::chunk {
- background-color: #4CAF50;
- }
- QScrollArea {
- border: none;
- }
- """
- self.setStyleSheet(dark_stylesheet)
- # Ustaw atrybut deleteButton dla przycisków usuwania
- self.delete_instance_button.setProperty("deleteButton", "true")
- self.delete_instance_button.style().unpolish(self.delete_instance_button)
- self.delete_instance_button.style().polish(self.delete_instance_button)
- def update_instance_tiles(self):
- """
- Odświeża listę instancji w UI.
- """
- logging.info("Odświeżanie listy instancji...")
- current_selection_path = self.selected_instance_dir
- self.instance_list.clear()
- self.selected_instance_dir = None
- instances = self.launcher.get_instance_list()
- if not instances:
- self.status_label.setText("Brak instancji. Stwórz nową (Plik -> Nowa instancja...).")
- self.main_info_label.setText("Brak instancji. Stwórz nową instancję (Plik -> Nowa instancja...).")
- self.update_buttons_state()
- return
- found_selected_index = -1
- for i, (name, path) in enumerate(instances):
- item = QListWidgetItem(name)
- item.setData(Qt.ItemDataRole.UserRole, path)
- self.instance_list.addItem(item)
- if path == current_selection_path:
- found_selected_index = i
- logging.info(f"Znaleziono {len(instances)} instancji.")
- self.status_label.setText(f"Znaleziono {len(instances)} instancji.")
- self.main_info_label.setText("Wybierz instancję z listy po lewej lub stwórz nową instancję (Plik -> Nowa instancja...).")
- if found_selected_index != -1:
- self.instance_list.setCurrentRow(found_selected_index)
- else:
- if self.instance_list.count() > 0:
- self.instance_list.setCurrentRow(0)
- else:
- self.update_buttons_state()
- def handle_instance_selection_change(self):
- """
- Obsługuje zmianę wybranej instancji w liście.
- """
- current_item = self.instance_list.currentItem()
- if current_item:
- self.load_instance(current_item)
- else:
- self.selected_instance_dir = None
- self.update_buttons_state()
- self.status_label.setText("Gotowy.")
- def create_instance(self):
- if self.launcher.current_download_thread or self.launcher.download_queue:
- QMessageBox.warning(self, "Pobieranie aktywne", "Inny proces pobierania jest aktywny. Poczekaj na jego zakończenie lub anuluj.")
- return
- dialog = CreateInstanceDialog(self.launcher, self)
- if dialog.exec():
- data = dialog.get_data()
- try:
- self.launcher.create_instance(
- name=data["name"],
- version_id=data["version"],
- modloader=data["modloader"],
- ram=data["ram"],
- java_path_setting=data["java_path_setting"],
- jvm_args_extra=data["jvm_args_extra"],
- base_instance_dir_input=data["base_instance_dir_input"],
- parent_window=self
- )
- except (ValueError, FileExistsError, FileNotFoundError, ConnectionError, PermissionError, Exception) as e:
- error_title = "Błąd tworzenia instancji"
- if isinstance(e, FileExistsError):
- error_title = "Katalog instancji już istnieje"
- elif isinstance(e, FileNotFoundError):
- error_title = "Wymagany plik/folder nie znaleziono"
- elif isinstance(e, ConnectionError):
- error_title = "Błąd połączenia sieciowego"
- elif isinstance(e, PermissionError):
- error_title = "Błąd uprawnień (klucz API?)"
- QMessageBox.critical(self, error_title, f"Nie udało się przygotować instancji:\n{e}")
- self.update_instance_tiles()
- def import_instance(self):
- file, _ = QFileDialog.getOpenFileName(self, "Importuj instancję", "", "Archiwa ZIP (*.zip);;Wszystkie pliki (*)")
- if file:
- if self.launcher.current_download_thread or self.launcher.download_queue:
- QMessageBox.warning(self, "Pobieranie aktywne", "Inny proces pobierania jest aktywny. Poczekaj na jego zakończenie lub anuluj.")
- return
- try:
- imported_dir = self.launcher.import_instance(file)
- QMessageBox.information(self, "Sukces", f"Instancja zaimportowana pomyślnie do:\n{imported_dir}")
- self.update_instance_tiles()
- except (FileNotFoundError, zipfile.BadZipFile, ValueError, Exception) as e:
- error_title = "Błąd importu instancji"
- if isinstance(e, FileNotFoundError):
- error_title = "Plik nie znaleziono"
- elif isinstance(e, zipfile.BadZipFile):
- error_title = "Nieprawidłowy plik ZIP"
- QMessageBox.critical(self, error_title, f"Nie udało się zaimportować instancji:\n{e}")
- def export_instance(self):
- current_item = self.instance_list.currentItem()
- if not current_item or not self.selected_instance_dir:
- QMessageBox.warning(self, "Brak wybranej instancji", "Proszę wybrać instancję do eksportu.")
- return
- instance_name = current_item.text()
- instance_dir_path = self.selected_instance_dir
- if not Path(instance_dir_path).exists():
- self.update_instance_tiles()
- return
- default_filename = f"{instance_name}.zip"
- start_dir = str(Path(instance_dir_path).parent)
- if not Path(start_dir).exists():
- start_dir = str(Path.home())
- file, _ = QFileDialog.getSaveFileName(self, f"Eksportuj instancję '{instance_name}'", os.path.join(start_dir, default_filename), "Archiwa ZIP (*.zip);;Wszystkie pliki (*)")
- if file:
- if not file.lower().endswith('.zip'):
- file += '.zip'
- try:
- self.launcher.export_instance(instance_dir_path, file)
- QMessageBox.information(self, "Sukces", f"Instancja '{instance_name}' wyeksportowana pomyślnie do:\n{file}")
- except (FileNotFoundError, IOError, Exception) as e:
- error_title = "Błąd eksportu instancji"
- if isinstance(e, FileNotFoundError):
- error_title = "Katalog instancji nie znaleziono"
- elif isinstance(e, IOError):
- error_title = "Błąd zapisu pliku"
- QMessageBox.critical(self, error_title, f"Nie udało się wyeksportować instancji '{instance_name}':\n{e}")
- def delete_instance(self):
- current_item = self.instance_list.currentItem()
- if not current_item or not self.selected_instance_dir:
- QMessageBox.warning(self, "Brak wybranej instancji", "Proszę wybrać instancję do usunięcia.")
- self.delete_instance_button.setEnabled(False)
- return
- instance_name = current_item.text()
- instance_dir_path = self.selected_instance_dir
- instance_dir = Path(instance_dir_path)
- if not instance_dir.exists():
- QMessageBox.warning(self, "Błąd", "Katalog instancji już nie istnieje. Odświeżam listę.")
- self.update_instance_tiles()
- return
- reply = QMessageBox.question(self, "Potwierdzenie usunięcia",
- f"Czy na pewno chcesz usunąć instancję '{instance_name}'?\n\nTa operacja jest nieodwracalna i usunie wszystkie pliki instancji (zapisy gry, mody, ustawienia itp.) z katalogu:\n{instance_dir_path}",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- QMessageBox.StandardButton.No)
- if reply == QMessageBox.StandardButton.Yes:
- try:
- logging.info(f"Usuwanie instancji: {instance_dir_path}")
- shutil.rmtree(instance_dir)
- logging.info("Instancja usunięta pomyślnie.")
- QMessageBox.information(self, "Sukces", f"Instancja '{instance_name}' została usunięta.")
- self.update_instance_tiles()
- except Exception as e:
- logging.error(f"Błąd podczas usuwania instancji {instance_dir_path}: {e}")
- QMessageBox.critical(self, "Błąd usuwania instancji", f"Nie udało się usunąć instancji '{instance_name}':\n{e}")
- def load_instance(self, item):
- instance_name = item.text()
- instance_dir_path = item.data(Qt.ItemDataRole.UserRole)
- logging.info(f"Wybrano instancję: '{instance_name}' w katalogu {instance_dir_path}")
- instance_dir = Path(instance_dir_path)
- if not instance_dir.exists():
- logging.error(f"Katalog instancji nie istnieje: {instance_dir_path}")
- self.instance_list.takeItem(self.instance_list.row(item))
- self.handle_instance_selection_change()
- return
- self.selected_instance_dir = instance_dir_path
- settings_path = instance_dir / "settings.json"
- has_settings = False
- version_id = None
- if settings_path.exists():
- try:
- with settings_path.open("r", encoding='utf-8') as f:
- settings = json.load(f)
- version_id = settings.get("version")
- has_settings = True
- except json.JSONDecodeError as e:
- logging.error(f"Błąd odczytu settings.json dla instancji {instance_name}: {e}")
- QMessageBox.critical(self, "Błąd ładowania instancji", f"Nie udało się odczytać ustawień instancji '{instance_name}'. Funkcje 'Graj' i 'Mody' mogą być niedostępne.")
- except Exception as e:
- logging.error(f"Nieoczekiwany błąd podczas ładowania settings.json dla instancji {instance_name}: {e}")
- QMessageBox.critical(self, "Błąd ładowania instancji", f"Wystąpił nieoczekiwany błąd podczas odczytu ustawień instancji '{instance_name}'.")
- self.play_button.setEnabled(has_settings and version_id is not None)
- self.mod_browser_button.setEnabled(has_settings and version_id is not None)
- self.delete_instance_button.setEnabled(True)
- def play_instance(self):
- current_item = self.instance_list.currentItem()
- instance_dir_path = self.selected_instance_dir
- if not current_item or not instance_dir_path:
- QMessageBox.warning(self, "Brak wybranej instancji", "Proszę wybrać instancję do uruchomienia.")
- self.play_button.setEnabled(False)
- return
- instance_name = current_item.text()
- if not Path(instance_dir_path).exists():
- QMessageBox.critical(self, "Błąd uruchamiania", "Katalog wybranej instancji nie istnieje! Odświeżam listę.")
- self.update_instance_tiles()
- return
- if self.launcher.current_download_thread or self.launcher.download_queue:
- QMessageBox.warning(self, "Pobieranie aktywne", "Inny proces pobierania jest aktywny. Proszę poczekać na jego zakończenie przed uruchomieniem gry.")
- return
- username = self.launcher.settings.get("default_account", DEFAULT_SETTINGS["default_account"])
- if not username or not username.strip():
- username, ok = QInputDialog.getText(self, "Nazwa gracza offline", "Wprowadź domyślną nazwę użytkownika offline:", text="Player")
- if not ok or not username.strip():
- QMessageBox.warning(self, "Anulowano", "Nazwa gracza jest wymagana do uruchomienia gry offline.")
- self.status_label.setText("Uruchomienie anulowane (brak nazwy gracza).")
- return
- username = username.strip()
- try:
- self.status_label.setText(f"Uruchamiam instancję: {instance_name}...")
- self.launcher.launch_game(instance_dir_path, username)
- self.status_label.setText(f"Uruchomiono instancję: {instance_name}")
- except (FileNotFoundError, ValueError, RuntimeError, TimeoutError, Exception) as e:
- error_title = "Błąd uruchamiania"
- if isinstance(e, FileNotFoundError):
- error_title = "Brak wymaganych plików"
- elif isinstance(e, ValueError):
- error_title = "Błąd konfiguracji instancji"
- elif isinstance(e, TimeoutError):
- error_title = "Przekroczono czas oczekiwania"
- logging.error(f"Błąd podczas uruchamiania instancji {instance_name}: {e}")
- QMessageBox.critical(self, error_title, f"Nie udało się uruchomić gry:\n{e}\nSprawdź logi launchera.")
- self.status_label.setText(f"Błąd uruchamiania instancji {instance_name}.")
- def open_mod_browser(self):
- current_item = self.instance_list.currentItem()
- instance_dir_path = self.selected_instance_dir
- if not current_item or not instance_dir_path:
- QMessageBox.warning(self, "Brak wybranej instancji", "Proszę wybrać instancję, dla której chcesz przeglądać mody.")
- self.mod_browser_button.setEnabled(False)
- return
- instance_name = current_item.text()
- if not Path(instance_dir_path).exists():
- QMessageBox.critical(self, "Błąd przeglądania modów", "Katalog wybranej instancji nie istnieje! Odświeżam listę.")
- self.update_instance_tiles()
- return
- settings_path = Path(instance_dir_path) / "settings.json"
- if not settings_path.exists():
- QMessageBox.warning(self, "Ustawienia instancji", f"Brak pliku settings.json dla instancji '{instance_name}'. Nie można przeglądać modów.")
- self.mod_browser_button.setEnabled(False)
- return
- try:
- with settings_path.open("r", encoding='utf-8') as f:
- settings = json.load(f)
- version_id = settings.get("version")
- if not version_id:
- QMessageBox.warning(self, "Wersja nieznana", f"Wersja gry dla instancji '{instance_name}' nie została poprawnie skonfigurowana. Nie można przeglądać modów.")
- self.mod_browser_button.setEnabled(False)
- return
- if self.launcher.current_download_thread or self.launcher.download_queue:
- QMessageBox.warning(self, "Pobieranie aktywne", "Inny proces pobierania jest aktywny. Proszę poczekać na jego zakończenie.")
- return
- dialog = ModBrowserDialog(self.launcher, version_id, instance_dir_path, self)
- dialog.exec()
- except json.JSONDecodeError as e:
- logging.error(f"Błąd odczytu settings.json dla instancji {instance_name}: {e}")
- QMessageBox.critical(self, "Błąd ładowania instancji", f"Nie udało się odczytać ustawień instancji '{instance_name}'.")
- except Exception as e:
- logging.error(f"Nieoczekiwany błąd podczas otwierania przeglądarki modów dla instancji {instance_name}: {e}")
- QMessageBox.critical(self, "Błąd przeglądania modów", f"Wystąpił nieoczekiwany błąd: {e}")
- def set_offline_account(self):
- current_username = self.launcher.settings.get("default_account", "")
- username, ok = QInputDialog.getText(self, "Ustaw nazwę konta offline", "Wprowadź domyślną nazwę użytkownika offline:", text=current_username)
- if ok and username:
- username = username.strip()
- if username:
- self.launcher.settings["default_account"] = username
- self.launcher.save_settings()
- QMessageBox.information(self, "Ustawiono konto", f"Domyślne konto offline ustawione na: '{username}'.")
- logging.info(f"Ustawiono domyślne konto offline: {username}")
- else:
- self.launcher.settings["default_account"] = ""
- self.launcher.save_settings()
- QMessageBox.information(self, "Ustawiono konto", "Domyślne konto offline zostało zresetowane. Nazwa będzie pytana przy uruchomieniu lub użyta domyślna 'Player'.")
- logging.info("Domyślne konto offline zresetowane.")
- def open_settings(self):
- dialog = QDialog(self)
- dialog.setWindowTitle("Ustawienia launchera")
- dialog.setMinimumWidth(400)
- layout = QVBoxLayout(dialog)
- layout.setSpacing(10)
- layout.addWidget(QLabel("Motyw interfejsu:"))
- theme_combo = QComboBox()
- theme_combo.addItems(["Light", "Night"])
- theme_combo.setCurrentText(self.launcher.settings.get("theme", "Light"))
- layout.addWidget(theme_combo)
- layout.addWidget(QLabel("Domyślna wersja Javy dla nowych instancji:"))
- java_combo = QComboBox()
- java_combo.addItem("Automatyczny wybór", userData="auto")
- sorted_java_versions = sorted(self.launcher.java_versions, key=lambda x: self.launcher.get_java_version_from_path(x[0]) or 0, reverse=True)
- for java_path, version in sorted_java_versions:
- major_v = self.launcher.get_java_version_from_path(java_path)
- java_combo.addItem(f"{version} (Java {major_v}) - {java_path}", userData=java_path)
- current_java_setting = self.launcher.settings.get("java_path", "auto")
- if current_java_setting.lower() == 'auto':
- java_combo.setCurrentText("Automatyczny wybór")
- else:
- found_index = java_combo.findData(current_java_setting)
- if found_index != -1:
- java_combo.setCurrentIndex(found_index)
- else:
- custom_item_text = f"Zapisana ścieżka: {current_java_setting} (Nieznana wersja)"
- java_combo.addItem(custom_item_text, userData=current_java_setting)
- java_combo.setCurrentIndex(java_combo.count() - 1)
- logging.warning(f"Zapisana ścieżka Javy w ustawieniach nie znaleziona wśród automatycznie wykrytych: {current_java_setting}. Dodano jako opcję niestandardową.")
- layout.addWidget(java_combo)
- layout.addWidget(QLabel("Domyślna pamięć RAM (np. 4G, 2048M):"))
- ram_input = QLineEdit(self.launcher.settings.get("ram", DEFAULT_SETTINGS["ram"]))
- layout.addWidget(ram_input)
- layout.addWidget(QLabel("Domyślne dodatkowe argumenty JVM:"))
- jvm_args_input = QLineEdit(self.launcher.settings.get("jvm_args", DEFAULT_SETTINGS["jvm_args"]))
- layout.addWidget(jvm_args_input)
- fullscreen_check = QCheckBox("Domyślnie pełny ekran")
- fullscreen_check.setChecked(self.launcher.settings.get("fullscreen", DEFAULT_SETTINGS["fullscreen"]))
- layout.addWidget(fullscreen_check)
- layout.addWidget(QLabel("Domyślna rozdzielczość (np. 1280x720):"))
- resolution_input = QLineEdit(self.launcher.settings.get("resolution", DEFAULT_SETTINGS["resolution"]))
- layout.addWidget(resolution_input)
- current_account_label = QLabel(f"Domyślne konto offline: {self.launcher.settings.get('default_account', 'Brak')}")
- layout.addWidget(current_account_label)
- button_layout = QHBoxLayout()
- save_button = QPushButton("Zapisz ustawienia")
- save_button.clicked.connect(dialog.accept)
- cancel_button = QPushButton("Anuluj")
- cancel_button.clicked.connect(dialog.reject)
- button_layout.addStretch(1)
- button_layout.addWidget(save_button)
- button_layout.addWidget(cancel_button)
- layout.addLayout(button_layout)
- if dialog.exec():
- selected_theme = theme_combo.currentText()
- selected_java_index = java_combo.currentIndex()
- selected_java_path_data = java_combo.itemData(selected_java_index)
- selected_java_path_to_save = selected_java_path_data if selected_java_path_data is not None else "auto"
- selected_ram = ram_input.text().strip().upper()
- selected_jvm_args = jvm_args_input.text().strip()
- selected_fullscreen = fullscreen_check.isChecked()
- selected_resolution = resolution_input.text().strip()
- if not re.match(r"^\d+[MG]$", selected_ram):
- QMessageBox.warning(dialog, "Nieprawidłowy format RAM", "Nieprawidłowy format pamięci RAM. Ustawienia nie zostały zapisane.")
- return
- if not re.match(r"^\d+x\d+$", selected_resolution):
- QMessageBox.warning(dialog, "Nieprawidłowy format rozdzielczości", "Nieprawidłowy format rozdzielczości. Ustawienia nie zostały zapisane.")
- return
- if selected_java_path_to_save != 'auto' and not Path(selected_java_path_to_save).exists():
- QMessageBox.warning(dialog, "Nieprawidłowa ścieżka Javy", f"Wybrana ścieżka Javy nie istnieje:\n{selected_java_path_to_save}. Ustawienia nie zostały zapisane.")
- return
- self.launcher.settings["theme"] = selected_theme
- self.launcher.settings["java_path"] = selected_java_path_to_save
- self.launcher.settings["ram"] = selected_ram
- self.launcher.settings["jvm_args"] = selected_jvm_args
- self.launcher.settings["fullscreen"] = selected_fullscreen
- self.launcher.settings["resolution"] = selected_resolution
- self.launcher.save_settings()
- self.apply_theme()
- logging.info("Ustawienia launchera zaktualizowane.")
- QMessageBox.information(self, "Sukces", "Ustawienia zostały zapisane.")
- def closeEvent(self, event):
- if self.launcher.progress_dialog and self.launcher.progress_dialog.isVisible():
- reply = QMessageBox.question(self, "Zamknąć?",
- "Pobieranie wciąż trwa. Czy na pewno chcesz zamknąć launcher i anulować pobieranie?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
- if reply == QMessageBox.StandardButton.Yes:
- self.launcher.progress_dialog.cancel_downloads()
- event.accept()
- else:
- event.ignore()
- else:
- event.accept()
- if __name__ == "__main__":
- signal.signal(signal.SIGINT, signal.SIG_DFL)
- app = QApplication(sys.argv)
- window = LauncherWindow()
- window.show()
- sys.exit(app.exec())
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement