· 5 months ago · May 11, 2025, 01:00 PM
1import sys
2import os
3import json
4import requests
5import hashlib
6import subprocess
7import zipfile
8import shutil
9import glob
10import re
11from PyQt6.QtWidgets import (
12 QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
13 QPushButton, QListWidget, QComboBox, QLineEdit, QProgressBar,
14 QLabel, QMessageBox, QMenuBar, QDialog, QInputDialog,
15 QFileDialog, QCheckBox, QTextEdit, QScrollArea, QSizePolicy,
16 QListWidgetItem # Dodano brakujący import
17)
18from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer
19from PyQt6.QtGui import QIcon, QPixmap
20import logging
21from datetime import datetime, timedelta # Import timedelta
22from collections import deque
23import time
24from pathlib import Path
25import signal # Potrzebne do obsługi Ctrl+C
26
27
28logging.basicConfig(
29 filename="launcher.log",
30 level=logging.INFO,
31 format="%(asctime)s - %(levelname)s - %(message)s"
32)
33
34
35CONFIG_DIR = Path.cwd() / "minecraft_launcher"
36SETTINGS_FILE = CONFIG_DIR / "settings.json"
37ASSETS_DIR = CONFIG_DIR / "assets"
38LIBRARIES_DIR = CONFIG_DIR / "libraries"
39INSTANCES_DIR = CONFIG_DIR / "instances"
40JAVA_DIR = CONFIG_DIR / "java"
41MOD_ICONS_DIR = CONFIG_DIR / "mod_icons"
42
43
44CURSEFORGE_API_KEY = "$2a$10$dxb5k5YbdGcnXYwM4U7CF.VWOtmsUP3xt3fDssBnjyPwCpEFpJgs."
45
46
47DEFAULT_SETTINGS = {
48 "theme": "Light",
49 "java_path": "",
50 "ram": "4G",
51 "jvm_args": "-XX:+UnlockExperimentalVMOptions",
52 "fullscreen": False,
53 "resolution": "1280x720",
54 "default_account": "Player"
55}
56
57
58STYLESHEET = """
59QDialog, QMainWindow {
60 background-color: #f0f0f0;
61 font-family: Arial;
62}
63QLabel {
64 font-size: 14px;
65 margin: 5px 0;
66}
67QProgressBar {
68 border: 1px solid #ccc;
69 border-radius: 5px;
70 text-align: center;
71 height: 20px;
72 background-color: #e0e0e0;
73}
74QProgressBar::chunk {
75 background-color: #4CAF50;
76 border-radius: 3px;
77}
78QPushButton {
79 background-color: #4CAF50;
80 color: white;
81 border: none;
82 padding: 8px;
83 border-radius: 5px;
84}
85QPushButton:hover {
86 background-color: #45a049;
87}
88QPushButton:disabled {
89 background-color: #cccccc;
90}
91QPushButton[deleteButton="true"] {
92 background-color: #f44336;
93}
94QPushButton[deleteButton="true"]:hover {
95 background-color: #d32f2f;
96}
97QPushButton[deleteButton="true"]:disabled {
98 background-color: #cccccc;
99}
100QListWidget {
101 border: 1px solid #ccc;
102 border-radius: 5px;
103 padding: 5px;
104 background-color: white;
105 alternate-background-color: #f5f5f5;
106}
107QScrollArea {
108 border: none;
109}
110QLineEdit, QComboBox, QTextEdit {
111 padding: 5px;
112 border: 1px solid #ccc;
113 border-radius: 4px;
114}
115"""
116
117def parse_version_type(version_id):
118 """
119 Rozpoznaje typ wersji Minecrafta (release, snapshot, alpha, beta) i zwraca krotkę (major, minor, patch).
120 Zwraca też flagę, czy wersja wymaga nowoczesnych argumentów (>=1.6).
121 """
122 # Snapshoty (np. 25w19a)
123 snapshot_match = re.match(r"(\d{2})w(\d{2})[a-z]", version_id)
124 if snapshot_match:
125 year = int(snapshot_match.group(1))
126 week = int(snapshot_match.group(2))
127 # Przyjmujemy, że snapshoty z 2025 to >= 1.21
128 major, minor = 1, 21
129 patch = 0
130 modern_args = True
131 logging.debug(f"Wersja {version_id} rozpoznana jako snapshot, zakładam {major}.{minor}.{patch}")
132 return (major, minor, patch), modern_args
133
134 # Standardowe wersje (np. 1.20.4)
135 standard_match = re.match(r"(\d+)\.(\d+)(?:\.(\d+))?", version_id)
136 if standard_match:
137 major = int(standard_match.group(1))
138 minor = int(standard_match.group(2))
139 patch = int(standard_match.group(3) or 0)
140 modern_args = (major, minor) >= (1, 6)
141 logging.debug(f"Wersja {version_id} rozpoznana jako standardowa, krotka: {major}.{minor}.{patch}")
142 return (major, minor, patch), modern_args
143
144 # Alpha/Beta (np. a1.0.16, b1.7.3)
145 old_match = re.match(r"[ab](\d+\.\d+\.\d+)", version_id)
146 if old_match:
147 logging.debug(f"Wersja {version_id} rozpoznana jako alpha/beta, zakładam 1.0.0")
148 return (1, 0, 0), False
149
150 # Fallback
151 logging.warning(f"Nie rozpoznano wersji {version_id}, zakładam 1.0.0")
152 return (1, 0, 0), False
153
154# Nowa klasa wątku do pobierania ikon
155class IconDownloadThread(QThread):
156 # Sygnał emitowany po pobraniu ikony: (mod_id, ścieżka_do_pliku)
157 icon_downloaded = pyqtSignal(int, str)
158
159 def __init__(self, mod_id, url, dest_path):
160 super().__init__()
161 self.mod_id = mod_id
162 self.url = url
163 self.dest_path = Path(dest_path)
164 # logging.debug(f"Utworzono wątek pobierania ikony dla mod ID {mod_id} z URL: {url}")
165
166 def run(self):
167 # Sprawdź jeszcze raz, czy plik nie został utworzony przez inny wątek w międzyczasie
168 if self.dest_path.exists():
169 logging.debug(f"Icon file already exists for mod ID {self.mod_id}: {self.dest_path.name}. Skipping download.")
170 self.icon_downloaded.emit(self.mod_id, str(self.dest_path))
171 return
172
173 logging.debug(f"Starting icon download for mod ID {self.mod_id} from {self.url}")
174 try:
175 self.dest_path.parent.mkdir(parents=True, exist_ok=True)
176 response = requests.get(self.url, timeout=10) # Krótki timeout dla ikon
177 response.raise_for_status()
178
179 with open(self.dest_path, "wb") as f:
180 f.write(response.content)
181
182 logging.debug(f"Icon downloaded successfully for mod ID {self.mod_id}: {self.dest_path.name}")
183 self.icon_downloaded.emit(self.mod_id, str(self.dest_path))
184
185 except requests.exceptions.RequestException as e:
186 logging.warning(f"Failed to download icon for mod ID {self.mod_id} from {self.url}: {e}")
187 # Emituj pustą ścieżkę lub None, aby wskazać błąd
188 self.icon_downloaded.emit(self.mod_id, "")
189 except Exception as e:
190 logging.error(f"Unexpected error during icon download for mod ID {self.mod_id}: {e}")
191 self.icon_downloaded.emit(self.mod_id, "")
192
193class DownloadThread(QThread):
194 progress = pyqtSignal(int)
195 total_progress = pyqtSignal(int)
196 finished = pyqtSignal(str, bool, str)
197 update_status = pyqtSignal(str, str)
198 update_speed = pyqtSignal(float, str)
199 update_size = pyqtSignal(float, str)
200 add_to_total_files = pyqtSignal(int)
201
202 def __init__(self, url, dest, download_type, sha1=None):
203 super().__init__()
204 self.url = url
205 self.dest = dest
206 self.download_type = download_type
207 self.sha1 = sha1
208 self.canceled = False
209
210 def run(self):
211 dest_path = Path(self.dest)
212 file_name = os.path.basename(self.dest)
213 logging.info(f"Starting download thread for: {self.url} -> {self.dest}")
214
215 if dest_path.exists() and self.sha1 and self.validate_sha1(self.dest, self.sha1):
216 logging.info(f"File {file_name} already exists and is valid. Skipping download.")
217 self.progress.emit(100)
218 self.finished.emit(self.dest, True, "skipped")
219 return
220
221 try:
222 dest_path.parent.mkdir(parents=True, exist_ok=True)
223 self.update_status.emit(self.download_type, file_name)
224
225 response = requests.get(self.url, stream=True, timeout=120)
226 response.raise_for_status()
227
228 total_size = int(response.headers.get("content-length", 0))
229 if total_size > 0:
230 unit, size = self._format_bytes(total_size)
231 self.update_size.emit(size, unit)
232 else:
233 self.update_size.emit(0, "Nieznany")
234
235 downloaded = 0
236 start_time = time.time()
237 last_update_time = start_time
238 last_downloaded_speed_check = 0
239 progress_interval = 0.5
240
241 with open(self.dest, "wb") as f:
242 for chunk in response.iter_content(chunk_size=8192):
243 if self.canceled:
244 logging.info(f"Download canceled for {file_name}")
245 try:
246 dest_path.unlink(missing_ok=True)
247 except Exception as cleanup_e:
248 logging.warning(f"Failed to clean up partial download {dest_path}: {cleanup_e}") # Poprawiono zmienną
249 self.finished.emit("", False, "Anulowano")
250 return
251 if chunk:
252 f.write(chunk)
253 downloaded += len(chunk)
254
255 current_time = time.time()
256 if current_time - last_update_time >= progress_interval and total_size > 0:
257 self.progress.emit(int(downloaded / total_size * 100))
258
259 delta_downloaded = downloaded - last_downloaded_speed_check
260 delta_time = current_time - last_update_time
261 if delta_time > 0:
262 speed = delta_downloaded / delta_time
263 speed_unit, speed_val = self._format_bytes(speed)
264 self.update_speed.emit(speed_val, f"{speed_unit}/s")
265
266 last_downloaded_speed_check = downloaded
267 last_update_time = current_time
268
269 if total_size > 0:
270 self.progress.emit(100)
271 else:
272 self.progress.emit(100)
273
274
275 if self.sha1 and not self.validate_sha1(self.dest, self.sha1):
276 logging.error(f"SHA1 validation failed for {file_name}. Expected: {self.sha1}")
277 try:
278 dest_path.unlink(missing_ok=True)
279 except Exception as cleanup_e:
280 logging.warning(f"Failed to clean up invalid file {dest_path}: {cleanup_e}") # Poprawiono zmienną
281 self.finished.emit(self.dest, False, "Walidacja SHA1 nieudana")
282 return
283
284 logging.info(f"Download successful: {file_name}")
285 self.finished.emit(self.dest, True, "")
286
287 except requests.exceptions.Timeout:
288 logging.error(f"Download timeout for {file_name}")
289 try:
290 dest_path.unlink(missing_ok=True)
291 except Exception as cleanup_e:
292 logging.warning(f"Failed to clean up partial download {dest_path}: {cleanup_e}") # Poprawiono zmienną
293 self.finished.emit(self.dest, False, "Timeout pobierania")
294 except requests.exceptions.RequestException as e:
295 logging.error(f"Download error for {file_name}: {e}")
296 try:
297 dest_path.unlink(missing_ok=True)
298 except Exception as cleanup_e:
299 logging.warning(f"Failed to clean up partial download {dest_path}: {cleanup_e}") # Poprawiono zmienną
300 self.finished.emit(self.dest, False, f"Błąd pobierania: {e}")
301 except Exception as e:
302 logging.error(f"An unexpected error occurred during download of {file_name}: {e}")
303 try:
304 dest_path.unlink(missing_ok=True)
305 except Exception as cleanup_e:
306 logging.warning(f"Failed to clean up partial download {dest_path}: {cleanup_e}") # Poprawiono zmienną
307 self.finished.emit(self.dest, False, f"Nieoczekiwany błąd: {e}")
308
309
310 def validate_sha1(self, file_path, expected_sha1):
311 sha1 = hashlib.sha1()
312 try:
313 if not expected_sha1 or not re.match(r'^[a-f0-9]{40}$', expected_sha1):
314 logging.warning(f"SHA1 validation skipped for {Path(file_path).name}: Invalid or missing SHA1 hash provided.")
315 return True
316
317 with open(file_path, "rb") as f:
318 for chunk in iter(lambda: f.read(8192), b""):
319 sha1.update(chunk)
320 calculated_sha1 = sha1.hexdigest()
321 is_valid = calculated_sha1 == expected_sha1
322 if not is_valid:
323 logging.warning(f"SHA1 mismatch for {Path(file_path).name}. Expected: {expected_sha1}, Got: {calculated_sha1}")
324 return is_valid
325 except FileNotFoundError:
326 logging.warning(f"SHA1 validation failed: file not found {file_path}")
327 return False
328 except Exception as e:
329 logging.error(f"Error during SHA1 validation for {file_path}: {e}")
330 return False
331
332 def cancel(self):
333 self.canceled = True
334
335 def _format_bytes(self, byte_count):
336 if byte_count is None or byte_count < 0:
337 return "B", 0
338 units = ["B", "KB", "MB", "GB", "TB"]
339 i = 0
340 while byte_count >= 1024 and i < len(units) - 1:
341 byte_count /= 1024
342 i += 1
343 return units[i], byte_count
344
345
346class DownloadProgressDialog(QDialog):
347 cancel_signal = pyqtSignal()
348 download_process_finished = pyqtSignal(bool)
349
350 # Dodano parametr launcher
351 def __init__(self, launcher, parent=None):
352 super().__init__(parent)
353 self.launcher = launcher # Przypisano launcher
354 self.setWindowTitle("Pobieranie zasobów...")
355 self.setMinimumSize(500, 400)
356 self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
357
358 self.total_files_expected = 0
359 self.downloaded_files_count = 0
360 self.successful_files_count = 0
361 self.skipped_files_count = 0
362 self.failed_downloads = []
363 self.is_cancelled = False
364 self.init_ui()
365 self._dialog_closed_by_signal = False
366
367 def init_ui(self):
368 layout = QVBoxLayout()
369 layout.setSpacing(10)
370
371 # Etykieta statusu
372 self.status_label = QLabel("Pobieranie...")
373 layout.addWidget(self.status_label)
374
375 # Pasek postępu pliku
376 self.file_progress_bar = QProgressBar()
377 self.file_progress_bar.setValue(0)
378 layout.addWidget(self.file_progress_bar)
379
380 # Pasek postępu całkowitego
381 self.total_progress_bar = QProgressBar()
382 self.total_progress_bar.setValue(0)
383 layout.addWidget(self.total_progress_bar)
384
385 # Informacje o plikach
386 self.files_label = QLabel("Pliki: 0/0 (0 pominięto)")
387 layout.addWidget(self.files_label)
388
389 # Prędkość
390 self.speed_label = QLabel("Prędkość: 0 KB/s")
391 layout.addWidget(self.speed_label)
392
393 # Rozmiar
394 self.size_label = QLabel("Rozmiar: ---")
395 layout.addWidget(self.size_label)
396
397 # Lista plików
398 self.file_list = QListWidget()
399 self.file_list.addItem("Oczekiwanie na rozpoczęcie...")
400 layout.addWidget(self.file_list)
401
402 # Przyciski
403 self.cancel_button = QPushButton("Anuluj")
404 self.cancel_button.clicked.connect(self.cancel_downloads)
405 layout.addWidget(self.cancel_button)
406
407 self.close_button = QPushButton("Zamknij")
408 self.close_button.clicked.connect(self.accept)
409 self.close_button.setVisible(False)
410 layout.addWidget(self.close_button)
411
412 self.setLayout(layout)
413
414 def set_total_files(self, count):
415 self.total_files_expected = count
416 self.files_label.setText(f"Pliki: {self.downloaded_files_count}/{self.total_files_expected} ({self.skipped_files_count} pominięto)")
417 self.update_total_progress()
418
419 def add_total_files(self, count):
420 self.total_files_expected += count
421 self.files_label.setText(f"Pliki: {self.downloaded_files_count}/{self.total_files_expected} ({self.skipped_files_count} pominięto)")
422
423
424 def update_status(self, download_type, file_name):
425 self.status_label.setText(f"Pobieranie {download_type}: {file_name}")
426 self.file_list.clear()
427 self.file_list.addItem(f"{file_name} ({download_type})")
428
429
430 def update_progress(self, value):
431 self.file_progress_bar.setValue(value)
432
433 def update_total_progress(self):
434 if self.total_files_expected > 0:
435 total_finished = self.downloaded_files_count + self.skipped_files_count
436 total_percentage = int((total_finished / self.total_files_expected) * 100)
437 self.total_progress_bar.setValue(total_percentage)
438 else:
439 pass
440
441
442 def update_speed(self, speed, unit):
443 self.speed_label.setText(f"Prędkość: {speed:.2f} {unit}")
444
445 def update_size(self, size, unit):
446 self.size_label.setText(f"Rozmiar: {size:.2f} {unit}" if unit != "Nieznany" else "Rozmiar: Nieznany")
447
448 def increment_downloaded(self, file_path, success=True, error_msg=""):
449 file_name = os.path.basename(file_path) if file_path else "Nieznany plik"
450
451 if error_msg == "skipped":
452 self.skipped_files_count += 1
453 self.successful_files_count += 1
454 logging.info(f"File skipped: {file_name}")
455 else:
456 self.downloaded_files_count += 1
457 if success:
458 self.successful_files_count += 1
459 logging.info(f"Download finished: {file_name}")
460 else:
461 self.failed_downloads.append(f"{file_name} ({error_msg})")
462 logging.error(f"Download failed: {file_name} - {error_msg}")
463
464
465 self.files_label.setText(f"Pliki: {self.downloaded_files_count}/{self.total_files_expected} ({self.skipped_files_count} pominięto)")
466 self.update_total_progress()
467 self.file_list.clear()
468 self.file_list.addItem("Oczekiwanie na następny...")
469
470
471 total_finished = self.downloaded_files_count + self.skipped_files_count
472 if total_finished >= self.total_files_expected and self.total_files_expected > 0:
473 self.on_all_downloads_finished()
474 elif self.is_cancelled:
475 pass
476
477
478 def on_all_downloads_finished(self):
479 logging.info("All downloads/checks processed.")
480 self.status_label.setText("Pobieranie zakończone!")
481 self.total_progress_bar.setValue(100)
482 self.file_progress_bar.setValue(100)
483 self.speed_label.setText("Prędkość: 0 KB/s")
484 self.size_label.setText("Rozmiar: ---")
485 self.file_list.clear()
486 self.file_list.addItem("Wszystkie pliki przetworzone.")
487
488
489 self.cancel_button.setVisible(False)
490 self.close_button.setVisible(True)
491 self.close_button.setEnabled(True)
492
493 overall_success = not self.failed_downloads and not self.is_cancelled
494
495 if self.failed_downloads:
496 msg = "Niektóre pliki nie zostały pobrane:\n" + "\n".join(self.failed_downloads)
497 QMessageBox.warning(self, "Pobieranie zakończone z błędami", msg)
498 logging.warning("Download finished with errors.")
499 elif self.is_cancelled:
500 self.status_label.setText("Pobieranie anulowane!")
501 logging.warning("Download process cancelled by user.")
502 else:
503 self.status_label.setText("Pobieranie zakończone pomyślnie!")
504 logging.info("Download finished successfully.")
505
506
507 QTimer.singleShot(100, lambda: self._emit_download_process_finished(overall_success))
508
509
510 def _emit_download_process_finished(self, success):
511 if not self._dialog_closed_by_signal:
512 self._dialog_closed_by_signal = True
513 self.download_process_finished.emit(success)
514 logging.debug(f"Emitted download_process_finished({success})")
515
516
517 def cancel_downloads(self):
518 if self.is_cancelled:
519 return
520
521 self.is_cancelled = True
522 self.cancel_button.setEnabled(False)
523 self.status_label.setText("Anulowanie...")
524 logging.info("User requested cancellation.")
525 self.cancel_signal.emit()
526
527 self.close_button.setVisible(True)
528 self.close_button.setEnabled(True)
529 self.file_list.clear()
530 self.file_list.addItem("Anulowano przez użytkownika.")
531
532
533 def closeEvent(self, event):
534 total_finished = self.downloaded_files_count + self.skipped_files_count
535 # Użyj przypisanego self.launcher
536 downloads_pending = total_finished < self.total_files_expected or (self.launcher and self.launcher.current_download_thread)
537
538 if downloads_pending and not self.is_cancelled:
539 reply = QMessageBox.question(self, "Zamknąć?",
540 "Pobieranie wciąż trwa. Czy na pewno chcesz zamknąć i anulować?",
541 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
542 if reply == QMessageBox.StandardButton.Yes:
543 self.cancel_downloads()
544 event.accept()
545 if not self._dialog_closed_by_signal:
546 self._emit_download_process_finished(False)
547 else:
548 event.ignore()
549
550 else:
551 if not self._dialog_closed_by_signal:
552 final_success = not self.failed_downloads and not self.is_cancelled
553 self._emit_download_process_finished(final_success)
554 event.accept()
555
556class MinecraftLauncher:
557 def __init__(self):
558 CONFIG_DIR.mkdir(parents=True, exist_ok=True)
559 ASSETS_DIR.mkdir(parents=True, exist_ok=True)
560 LIBRARIES_DIR.mkdir(parents=True, exist_ok=True)
561 INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
562 JAVA_DIR.mkdir(parents=True, exist_ok=True)
563 MOD_ICONS_DIR.mkdir(parents=True, exist_ok=True)
564
565 self.settings = self.load_settings()
566 self.accounts = []
567 self.java_versions = self.find_java_versions()
568
569 self.download_queue = deque()
570 self.current_download_thread = None
571 self.progress_dialog = None
572
573 self.logged_snapshots_modloader_warning = set()
574 self._post_download_data = None
575
576 def load_settings(self):
577 if SETTINGS_FILE.exists():
578 try:
579 with SETTINGS_FILE.open("r") as f:
580 loaded_settings = json.load(f)
581 settings = DEFAULT_SETTINGS.copy()
582 settings.update(loaded_settings)
583 return settings
584 except (json.JSONDecodeError, Exception) as e:
585 logging.error(f"Błąd odczytu/parsowania settings.json: {e}. Używam domyślnych.")
586 logging.info("settings.json nie znaleziono lub błąd odczytu, używam domyślnych.")
587 return DEFAULT_SETTINGS.copy()
588
589 def save_settings(self):
590 try:
591 with SETTINGS_FILE.open("w") as f:
592 json.dump(self.settings, f, indent=4)
593 logging.info("Ustawienia zapisane pomyślnie.")
594 except Exception as e:
595 logging.error(f"Błąd zapisu ustawień: {e}")
596
597 def _queue_download(self, url, dest, download_type, sha1=None):
598 dest_path = Path(dest)
599 temp_validator = DownloadThread(url, dest, download_type, sha1)
600 if dest_path.exists() and sha1 and temp_validator.validate_sha1(str(dest_path), sha1):
601 logging.info(f"Plik {Path(dest).name} już istnieje i jest poprawny. Pomijanie kolejkowania.")
602 return 0
603 else:
604 dest_path.parent.mkdir(parents=True, exist_ok=True)
605 self.download_queue.append((url, str(dest_path), download_type, sha1))
606 logging.debug(f"Dodano do kolejki: {Path(dest).name} ({download_type})")
607 return 1
608
609
610 def process_download_queue(self):
611 if self.current_download_thread is None and self.download_queue:
612 url, dest, download_type, sha1 = self.download_queue.popleft()
613 self.current_download_thread = DownloadThread(url, dest, download_type, sha1)
614
615 if self.progress_dialog:
616 try:
617 self.current_download_thread.progress.disconnect()
618 self.current_download_thread.update_status.disconnect()
619 self.current_download_thread.update_speed.disconnect()
620 self.current_download_thread.update_size.disconnect()
621 self.current_download_thread.finished.disconnect()
622 except TypeError:
623 pass
624
625 self.current_download_thread.progress.connect(self.progress_dialog.update_progress)
626 self.current_download_thread.update_status.connect(self.progress_dialog.update_status)
627 self.current_download_thread.update_speed.connect(self.progress_dialog.update_speed)
628 self.current_download_thread.update_size.connect(self.progress_dialog.update_size)
629 self.current_download_thread.finished.connect(self.on_download_thread_finished)
630
631
632 logging.debug(f"Starting download thread for: {Path(dest).name}")
633 self.current_download_thread.start()
634 elif self.current_download_thread is None and not self.download_queue:
635 logging.debug("Download queue is empty and no thread active. Download process should be complete.")
636 pass
637
638
639 def on_download_thread_finished(self, path, success, error_message):
640 file_name = os.path.basename(path) if path else "Unknown File"
641 logging.debug(f"Download thread finished for {file_name}. Success: {success}, Error: {error_message}")
642
643 if self.progress_dialog:
644 QTimer.singleShot(0, lambda: self.progress_dialog.increment_downloaded(path, success, error_message))
645
646 if self.current_download_thread:
647 self.current_download_thread.deleteLater()
648 self.current_download_thread = None
649
650 self.process_download_queue()
651
652
653 def cancel_downloads(self):
654 logging.info("Attempting to cancel downloads.")
655 if self.current_download_thread:
656 self.current_download_thread.cancel()
657
658 self.download_queue.clear()
659 logging.info("Download queue cleared.")
660
661
662 def _download_metadata_sync(self, url, dest, description):
663 dest_path = Path(dest)
664 logging.info(f"Pobieranie metadanych (synchronicznie): {description} z {url}")
665 dest_path.parent.mkdir(parents=True, exist_ok=True)
666 try:
667 response = requests.get(url, timeout=30)
668 response.raise_for_status()
669 with open(dest_path, "wb") as f:
670 f.write(response.content)
671 logging.info(f"Pobrano metadane: {dest_path.name}")
672 return str(dest_path)
673 except requests.exceptions.RequestException as e:
674 logging.error(f"Błąd pobierania metadanych {description} z {url}: {e}")
675 raise ValueError(f"Nie udało się pobrać metadanych {description}: {e}")
676 except Exception as e:
677 logging.error(f"Nieoczekiwany błąd podczas pobierania metadanych {description}: {e}")
678 raise RuntimeError(f"Nieoczekiwany błąd podczas pobierania metadanych {description}: {e}")
679
680
681 def _queue_version_files(self, version_id, instance_dir):
682 version_dir = Path(instance_dir) / "versions" / version_id
683 version_dir.mkdir(parents=True, exist_ok=True)
684 queued_count = 0
685
686 try:
687 try:
688 manifest = self.get_version_manifest()
689 version_info = next((v for v in manifest["versions"] if v["id"] == version_id), None)
690 if not version_info:
691 raise ValueError(f"Wersja {version_id} nie istnieje w manifeście!")
692 version_json_url = version_info["url"]
693 except Exception as e:
694 raise ValueError(f"Nie udało się uzyskać URL manifestu wersji dla {version_id}: {e}")
695
696 version_json_path = version_dir / f"{version_id}.json"
697 self._download_metadata_sync(version_json_url, version_json_path, "version JSON")
698
699 try:
700 with version_json_path.open("r", encoding='utf-8') as f:
701 version_data = json.load(f)
702 except json.JSONDecodeError as e:
703 logging.error(f"Błąd parsowania {version_json_path}: {e}")
704 raise ValueError(f"Nieprawidłowy plik wersji JSON: {version_json_path}")
705
706 client_info = version_data.get("downloads", {}).get("client")
707 if client_info:
708 client_url = client_info["url"]
709 client_sha1 = client_info.get("sha1")
710 client_path = version_dir / f"{version_id}.jar"
711 queued_count += self._queue_download(client_url, client_path, "client JAR", client_sha1)
712 else:
713 logging.warning(f"Brak danych klienta JAR w JSON dla wersji {version_id}. Kontynuuję bez kolejkowania client.jar")
714
715
716 asset_index_info = version_data.get("assetIndex")
717 if not asset_index_info:
718 logging.warning(f"Brak danych assetIndex w JSON dla wersji {version_id}. Kontynuuję bez kolejkowania assetów.")
719 asset_data = {}
720 else:
721 asset_index_id = asset_index_info["id"]
722 asset_index_url = asset_index_info["url"]
723 asset_index_sha1 = asset_index_info.get("sha1")
724 asset_index_path = Path(ASSETS_DIR) / "indexes" / f"{asset_index_id}.json"
725 self._download_metadata_sync(asset_index_url, asset_index_path, "asset index")
726
727 try:
728 with asset_index_path.open("r", encoding='utf-8') as f:
729 asset_data = json.load(f)
730 except json.JSONDecodeError as e:
731 logging.error(f"Błąd parsowania {asset_index_path}: {e}")
732 raise ValueError(f"Nieprawidłowy plik indexu assetów: {asset_index_path}")
733 except FileNotFoundError:
734 logging.warning(f"Plik indexu assetów nie znaleziono po pobraniu (??): {asset_index_path}")
735 asset_data = {}
736
737 for hash_path, info in asset_data.get("objects", {}).items():
738 hash = info["hash"]
739 obj_path = Path(ASSETS_DIR) / "objects" / hash[:2] / hash
740 obj_url = f"https://resources.download.minecraft.net/{hash[:2]}/{hash}"
741 queued_count += self._queue_download(obj_url, obj_path, "asset", hash)
742
743 for lib in version_data.get("libraries", []):
744 if self._is_library_applicable(lib):
745 if "downloads" in lib and "artifact" in lib["downloads"]:
746 artifact = lib["downloads"]["artifact"]
747 lib_path = Path(LIBRARIES_DIR) / artifact["path"]
748 queued_count += self._queue_download(artifact["url"], lib_path, "library", artifact.get("sha1"))
749
750 classifiers = lib["downloads"].get("classifiers", {})
751 native_classifier_data = None
752
753 current_os_key = sys.platform
754 if current_os_key == "win32":
755 current_os_key = "windows"
756 elif current_os_key == "darwin":
757 current_os_key = "macos"
758 elif current_os_key.startswith("linux"):
759 current_os_key = "linux"
760
761 arch = '64' if sys.maxsize > 2**32 else '32'
762
763 search_keys = [
764 f"natives-{current_os_key}-{arch}",
765 f"natives-{current_os_key}",
766 ]
767
768 for key in search_keys:
769 if key in classifiers:
770 native_classifier_data = classifiers[key]
771 logging.debug(f"Znaleziono klasyfikator natywny '{key}' dla biblioteki {lib.get('name', 'Nieznana')}")
772 break
773
774 if native_classifier_data:
775 native_url = native_classifier_data["url"]
776 native_sha1 = native_classifier_data.get("sha1")
777 lib_name = lib.get("name", "unknown_lib").replace(":", "_").replace(".", "_")
778 classifier_file_name = Path(native_classifier_data["path"]).name
779 native_zip_path = version_dir / "natives_zips" / f"{lib_name}_{classifier_file_name}"
780 queued_count += self._queue_download(native_url, native_zip_path, "native zip", native_sha1)
781 else:
782 logging.debug(f"Brak natywnego klasyfikatora dla biblioteki {lib.get('name', 'Nieznana')} dla systemu {current_os_key}-{arch}")
783
784 except ValueError as e:
785 logging.error(f"Błąd podczas kolejkowania plików wersji: {e}")
786 raise
787 except RuntimeError as e:
788 logging.error(f"Błąd krytyczny podczas kolejkowania plików wersji: {e}")
789 raise
790 except Exception as e:
791 logging.error(f"Nieoczekiwany błąd podczas kolejkowania plików wersji: {e}")
792 raise
793
794 return queued_count
795
796 def validate_modloader(self, modloader, version_id):
797 if not version_id: return False
798
799 is_snapshot = re.match(r"^\d+w\d+[a-z]$", version_id)
800 if is_snapshot:
801 if version_id not in self.logged_snapshots_modloader_warning:
802 logging.warning(f"Wersja {version_id} to snapshot, wsparcie modloaderów jest ograniczone lub nieistniejące.")
803 self.logged_snapshots_modloader_warning.add(version_id)
804 return False
805
806 try:
807 parts = version_id.split('.')
808 major = int(parts[0])
809 minor = int(parts[1]) if len(parts) > 1 else 0
810 patch = int(parts[2]) if len(parts) > 2 else 0
811 version_tuple = (major, minor, patch)
812 version_tuple += (0,) * (3 - len(version_tuple))
813
814 except ValueError:
815 logging.error(f"Nie można sparsować wersji Minecrafta '{version_id}' dla walidacji modloadera. Zakładam brak wsparcia.")
816 return False
817
818 if modloader == "forge":
819 if version_tuple < (1, 5, 2): return False
820
821 elif modloader == "neoforge":
822 if version_tuple < (1, 20, 1): return False
823
824 elif modloader == "fabric":
825 if version_tuple < (1, 14, 0): return False
826
827 elif modloader == "quilt":
828 if version_tuple < (1, 14, 0): return False
829
830 return True
831
832 def _queue_modloader_installer(self, modloader, version_id, instance_dir):
833 if not self.validate_modloader(modloader, version_id):
834 raise ValueError(f"{modloader.capitalize()} prawdopodobnie nie wspiera wersji {version_id}!")
835
836 modloader_dir = Path(instance_dir) / "modloaders"
837 modloader_dir.mkdir(parents=True, exist_ok=True)
838 queued_count = 0
839 url = None
840 installer_name = None
841
842 if modloader in ["forge", "neoforge"]:
843 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}")
844 pass
845
846 elif modloader == "fabric":
847 installer_name = "fabric-installer.jar"
848 url = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/0.11.2/fabric-installer-0.11.2.jar"
849 installer_path = modloader_dir / installer_name
850 queued_count += self._queue_download(url, installer_path, "fabric installer")
851
852 elif modloader == "quilt":
853 installer_name = "quilt-installer.jar"
854 url = "https://maven.quiltmc.org/repository/release/org/quiltmc/quilt-installer/0.10.0/quilt-installer-0.10.0.jar"
855 installer_path = modloader_dir / installer_name
856 queued_count += self._queue_download(url, installer_path, "quilt installer")
857
858 else:
859 raise ValueError(f"Nieznany modloader: {modloader}")
860
861 return queued_count
862
863
864 def _run_modloader_installer(self, modloader, version_id, instance_dir):
865 modloader_dir = Path(instance_dir) / "modloaders"
866 installer_path = None
867 installer_args = []
868 success_message = ""
869 error_message = ""
870
871 java_path = self.find_java_for_version(version_id)
872 if not java_path or not Path(java_path).exists():
873 raise ValueError(f"Nie znaleziono kompatybilnej Javy dla wersji {version_id} lub ścieżka Javy jest niepoprawna. Zainstaluj Javę i/lub sprawdź ustawienia.")
874
875 if modloader == "forge":
876 forge_installers = list(modloader_dir.glob("forge-*-installer.jar"))
877 if not forge_installers:
878 raise FileNotFoundError(f"Nie znaleziono instalatora Forge (.jar) w katalogu instancji: {modloader_dir}. Pobierz go ręcznie i umieść w tym katalogu.")
879 if len(forge_installers) > 1:
880 logging.warning(f"Znaleziono wiele plików instalatora Forge w {modloader_dir}. Używam pierwszego: {forge_installers[0].name}")
881 installer_path = forge_installers[0]
882 installer_args = ["--installClient", str(instance_dir)]
883 success_message = f"Zainstalowano Forge dla wersji {version_id}"
884 error_message = "Błąd instalacji Forge."
885
886 elif modloader == "neoforge":
887 neoforge_installers = list(modloader_dir.glob("neoforge-*-installer.jar"))
888 if not neoforge_installers:
889 raise FileNotFoundError(f"Nie znaleziono instalatora NeoForge (.jar) w katalogu instancji: {modloader_dir}. Pobierz go ręcznie i umieść w tym katalogu.")
890 if len(neoforge_installers) > 1:
891 logging.warning(f"Znaleziono wiele plików instalatora NeoForge w {modloader_dir}. Używam pierwszego: {neoforge_installers[0].name}")
892 installer_path = neoforge_installers[0]
893 installer_args = ["--installClient", str(instance_dir)]
894 success_message = f"Zainstalowano NeoForge dla wersji {version_id}"
895 error_message = "Błąd instalacji NeoForge."
896
897 elif modloader == "fabric":
898 installer_path = modloader_dir / "fabric-installer.jar"
899 installer_args = ["client", "-mcversion", version_id, "-dir", str(instance_dir)]
900 success_message = f"Zainstalowano Fabric dla wersji {version_id}"
901 error_message = "Błąd instalacji Fabric."
902
903 elif modloader == "quilt":
904 installer_path = modloader_dir / "quilt-installer.jar"
905 installer_args = ["install", "client", version_id, "--install-dir", str(instance_dir)]
906 success_message = f"Zainstalowano Quilt dla wersji {version_id}"
907 error_message = "Błąd instalacji Quilt."
908
909 else:
910 logging.error(f"Próba uruchomienia instalatora dla nieznanego modloadera: {modloader}")
911 return
912
913 if installer_path is None or not installer_path.exists():
914 if modloader in ["fabric", "quilt"]:
915 raise FileNotFoundError(f"Instalator {modloader.capitalize()} (.jar) nie znaleziono w katalogu: {installer_path}. Pobieranie mogło się nie udać.")
916 else:
917 raise FileNotFoundError(f"Instalator modloadera nie znaleziono: {installer_path}")
918
919 cmd = [java_path, "-jar", str(installer_path)] + installer_args
920 logging.info(f"Uruchamianie instalatora modloadera: {' '.join([str(c) for c in cmd])}")
921
922 try:
923 result = subprocess.run(cmd, cwd=str(modloader_dir), capture_output=True, text=True, timeout=300)
924 logging.info(f"Instalator stdout:\n{result.stdout}")
925 logging.info(f"Instalator stderr:\n{result.stderr}")
926
927 if result.returncode != 0:
928 detailed_error = f"{error_message} Proces zakończył się kodem {result.returncode}.\nStderr:\n{result.stderr}"
929 raise subprocess.CalledProcessError(result.returncode, cmd, output=result.stdout, stderr=result.stderr)
930
931 logging.info(success_message)
932
933 if modloader in ["fabric", "quilt"]:
934 try:
935 installer_path.unlink()
936 logging.debug(f"Usunięto instalator: {installer_path}")
937 except Exception as e:
938 logging.warning(f"Nie udało się usunąć instalatora {installer_path}: {e}")
939
940 except FileNotFoundError:
941 logging.error(f"Plik wykonywalny instalatora lub Javy nie istnieje: {installer_path} lub {java_path}")
942 raise FileNotFoundError(f"Plik wykonywalny instalatora modloadera lub Javy nie istnieje. Sprawdź ścieżki.")
943 except subprocess.TimeoutExpired:
944 logging.error(f"Instalator modloadera przekroczył czas oczekiwania (Timeout).")
945 raise TimeoutError(f"Instalator modloadera przekroczył czas oczekiwania. Spróbuj ponownie lub zwiększ limit czasu.")
946 except subprocess.CalledProcessError as e:
947 logging.error(f"Instalator modloadera zakończył się błędem:\n{e.stderr}\n{e}")
948 raise ValueError(f"Instalator modloadera zakończył się błędem (Kod: {e.returncode}). Sprawdź logi lub dane wyjściowe instalatora.")
949 except Exception as e:
950 logging.error(f"Nieoczekiwany błąd podczas uruchamiania instalatora modloadera: {e}")
951 raise RuntimeError(f"Nieoczekiwany błąd podczas uruchamiania instalatora modloadera: {e}")
952
953
954 def get_version_manifest(self):
955 url = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"
956 manifest_path = CONFIG_DIR / "version_manifest_v2.json"
957 if manifest_path.exists():
958 try:
959 with manifest_path.open("r", encoding='utf-8') as f:
960 manifest_data = json.load(f)
961 if datetime.fromtimestamp(manifest_path.stat().st_mtime) > datetime.now() - timedelta(hours=1):
962 logging.info("Używam cache version_manifest_v2.json")
963 return manifest_data
964 except Exception as e:
965 logging.warning(f"Błąd odczytu cache manifestu wersji: {e}. Pobieram nowy.")
966
967 try:
968 logging.info(f"Pobieranie manifestu wersji z: {url}")
969 response = requests.get(url, timeout=15)
970 response.raise_for_status()
971 manifest_data = response.json()
972 try:
973 with manifest_path.open("w", encoding='utf-8') as f:
974 json.dump(manifest_data, f, indent=4)
975 logging.info("Zapisano version_manifest_v2.json do cache.")
976 except Exception as e:
977 logging.warning(f"Błąd zapisu cache manifestu wersji: {e}")
978
979 return manifest_data
980 except requests.exceptions.RequestException as e:
981 logging.error(f"Błąd pobierania manifestu wersji: {e}")
982 if manifest_path.exists():
983 try:
984 with manifest_path.open("r", encoding='utf-8') as f:
985 logging.warning("Pobieranie nieudane, używam starego cache manifestu wersji.")
986 return json.load(f)
987 except Exception as e_cache:
988 logging.error(f"Błąd odczytu starego cache manifestu wersji: {e_cache}")
989 raise ConnectionError("Brak połączenia z internetem i brak dostępnego manifestu wersji!")
990 else:
991 raise ConnectionError("Brak połączenia z internetem i brak dostępnego manifestu wersji!")
992
993
994 def get_curseforge_mods(self, search_query, version_id):
995 headers = {"x-api-key": CURSEFORGE_API_KEY}
996 params = {"gameId": 432, "searchFilter": search_query, "minecraftVersion": version_id, "classId": 6, "sortField": 2}
997 url = "https://api.curseforge.com/v1/mods/search"
998 logging.info(f"Wyszukiwanie modów: '{search_query}' dla wersji {version_id}")
999 try:
1000 response = requests.get(url, headers=headers, params=params, timeout=15)
1001 response.raise_for_status()
1002 data = response.json().get("data", [])
1003 logging.info(f"Znaleziono {len(data)} modów dla '{search_query}'")
1004 return data
1005 except requests.exceptions.RequestException as e:
1006 logging.error(f"Błąd wyszukiwania modów z CurseForge: {e}")
1007 if hasattr(e, 'response') and e.response is not None:
1008 logging.error(f"CurseForge API Response status: {e.response.status_code}, body: {e.response.text}")
1009 if e.response.status_code == 403:
1010 raise PermissionError("Błąd API CurseForge: Klucz API jest nieprawidłowy lub brak dostępu.")
1011 if e.response.status_code == 429:
1012 raise requests.exceptions.RequestException("Błąd API CurseForge: Limit żądań przekroczony. Spróbuj ponownie później.")
1013
1014 raise requests.exceptions.RequestException(f"Nie udało się wyszukać modów: {e}")
1015
1016
1017 def _queue_curseforge_mod_files(self, mod_id, version_id, instance_dir, download_dependencies=False, visited_mods=None):
1018 if visited_mods is None:
1019 visited_mods = set()
1020
1021 if mod_id in visited_mods:
1022 logging.debug(f"Mod ID {mod_id} już przetworzony, pomijam.")
1023 return 0
1024
1025 visited_mods.add(mod_id)
1026 logging.debug(f"Processing mod ID {mod_id} for version {version_id}")
1027
1028 headers = {"x-api-key": CURSEFORGE_API_KEY}
1029 total_queued = 0
1030 try:
1031 files_url = f"https://api.curseforge.com/v1/mods/{mod_id}/files"
1032 response = requests.get(files_url, headers=headers, timeout=15)
1033 response.raise_for_status()
1034 files = response.json().get("data", [])
1035
1036 compatible_file = None
1037 files.sort(key=lambda x: x.get('fileDate', '1970-01-01T00:00:00Z'), reverse=True)
1038
1039 for file in files:
1040 if version_id in file.get("gameVersions", []):
1041 compatible_file = file
1042 break
1043
1044 if not compatible_file:
1045 try:
1046 mod_info_resp = requests.get(f"https://api.curseforge.com/v1/mods/{mod_id}", headers=headers, timeout=5)
1047 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}"
1048 except Exception:
1049 mod_name = f"ID {mod_id}"
1050
1051 logging.warning(f"Brak kompatybilnego pliku moda dla {mod_name} (ID: {mod_id}) i wersji {version_id}.")
1052 return 0
1053
1054 mod_url = compatible_file.get("downloadUrl")
1055 mod_name = compatible_file.get("fileName")
1056 if not mod_url:
1057 logging.warning(f"Download URL is null for mod file {mod_name} (Mod ID: {mod_id}). Skipping.")
1058 return 0
1059
1060 mod_path = Path(instance_dir) / "mods" / mod_name
1061 mod_path.parent.mkdir(parents=True, exist_ok=True)
1062
1063 total_queued += self._queue_download(mod_url, mod_path, "mod")
1064
1065 if download_dependencies:
1066 logging.debug(f"Checking dependencies for mod ID {mod_id}")
1067 for dep in compatible_file.get("dependencies", []):
1068 if dep.get("relationType") == 3:
1069 dep_mod_id = dep.get("modId")
1070 if dep_mod_id:
1071 logging.debug(f"Queueing required dependency ID {dep_mod_id} for mod ID {mod_id}")
1072 total_queued += self._queue_curseforge_mod_files(dep_mod_id, version_id, instance_dir, download_dependencies=True, visited_mods=visited_mods)
1073 else:
1074 logging.warning(f"Dependency found with null modId for mod ID {mod_id}. Skipping.")
1075
1076 except requests.exceptions.RequestException as e:
1077 logging.error(f"Błąd pobierania plików moda dla mod ID {mod_id}: {e}")
1078 return 0
1079 except Exception as e:
1080 logging.error(f"Nieoczekiwany błąd podczas kolejkowania plików moda dla mod ID {mod_id}: {e}")
1081 return 0
1082
1083 return total_queued
1084
1085
1086 def remove_mod(self, mod_file_name, instance_dir):
1087 mod_path = Path(instance_dir) / "mods" / mod_file_name
1088 if mod_path.exists():
1089 try:
1090 mod_path.unlink()
1091 logging.info(f"Usunięto mod: {mod_path}")
1092 except Exception as e:
1093 logging.error(f"Błąd usuwania moda {mod_path}: {e}")
1094 raise IOError(f"Nie udało się usunąć moda: {e}")
1095 else:
1096 logging.warning(f"Mod {mod_file_name} nie istnieje w {instance_dir}/mods (pomijam usuwanie)")
1097 raise FileNotFoundError(f"Mod {mod_file_name} nie znaleziono w katalogu {instance_dir}/mods!")
1098
1099 def _validate_and_queue_missing_files(self, instance_dir, version_id):
1100 """
1101 Sprawdza, czy wszystkie potrzebne pliki (client.jar, libraries, natives, assets) istnieją.
1102 Jeśli czegoś brakuje, dodaje brakujące pliki do kolejki pobierania.
1103 Zwraca liczbę brakujących plików dodanych do kolejki.
1104 """
1105 logging.info(f"Sprawdzanie brakujących plików dla wersji {version_id} w instancji {instance_dir}")
1106 queued_count = 0
1107 version_dir = Path(instance_dir) / "versions" / version_id
1108 version_json_path = version_dir / f"{version_id}.json"
1109
1110 # Sprawdzenie version.json
1111 if not version_json_path.exists():
1112 logging.warning(f"Brak pliku {version_json_path}. Pobieram ponownie.")
1113 try:
1114 manifest = self.get_version_manifest()
1115 version_info = next((v for v in manifest["versions"] if v["id"] == version_id), None)
1116 if not version_info:
1117 raise ValueError(f"Wersja {version_id} nie istnieje w manifeście!")
1118 version_json_url = version_info["url"]
1119 self._download_metadata_sync(version_json_url, version_json_path, "version JSON")
1120 except Exception as e:
1121 logging.error(f"Błąd pobierania version.json dla {version_id}: {e}")
1122 raise ValueError(f"Nie udało się pobrać version.json: {e}")
1123
1124 # Wczytanie version.json
1125 try:
1126 with version_json_path.open("r", encoding='utf-8') as f:
1127 version_data = json.load(f)
1128 except json.JSONDecodeError as e:
1129 logging.error(f"Błąd parsowania {version_json_path}: {e}")
1130 raise ValueError(f"Nieprawidłowy plik wersji JSON: {version_json_path}")
1131
1132 # Sprawdzenie client.jar
1133 client_info = version_data.get("downloads", {}).get("client")
1134 if client_info:
1135 client_path = version_dir / f"{version_id}.jar"
1136 client_sha1 = client_info.get("sha1")
1137 if not client_path.exists() or (client_sha1 and not self._validate_sha1(str(client_path), client_sha1)):
1138 logging.warning(f"Brak lub uszkodzony client.jar: {client_path}. Dodaję do kolejki.")
1139 queued_count += self._queue_download(client_info["url"], client_path, "client JAR", client_sha1)
1140 else:
1141 logging.debug(f"client.jar istnieje i jest poprawny: {client_path}")
1142
1143 # Sprawdzenie assetów
1144 asset_index_info = version_data.get("assetIndex")
1145 if asset_index_info:
1146 asset_index_id = asset_index_info["id"]
1147 asset_index_path = Path(ASSETS_DIR) / "indexes" / f"{asset_index_id}.json"
1148 asset_index_sha1 = asset_index_info.get("sha1")
1149 if not asset_index_path.exists() or (asset_index_sha1 and not self._validate_sha1(str(asset_index_path), asset_index_sha1)):
1150 logging.warning(f"Brak lub uszkodzony index assetów: {asset_index_path}. Pobieram ponownie.")
1151 self._download_metadata_sync(asset_index_info["url"], asset_index_path, "asset index")
1152
1153 try:
1154 with asset_index_path.open("r", encoding='utf-8') as f:
1155 asset_data = json.load(f)
1156 except json.JSONDecodeError as e:
1157 logging.error(f"Błąd parsowania {asset_index_path}: {e}")
1158 raise ValueError(f"Nieprawidłowy plik indexu assetów: {asset_index_path}")
1159
1160 for hash_path, info in asset_data.get("objects", {}).items():
1161 hash = info["hash"]
1162 obj_path = Path(ASSETS_DIR) / "objects" / hash[:2] / hash
1163 if not obj_path.exists() or not self._validate_sha1(str(obj_path), hash):
1164 obj_url = f"https://resources.download.minecraft.net/{hash[:2]}/{hash}"
1165 logging.debug(f"Brak lub uszkodzony asset: {obj_path}. Dodaję do kolejki.")
1166 queued_count += self._queue_download(obj_url, obj_path, "asset", hash)
1167 else:
1168 logging.debug(f"Asset istnieje i jest poprawny: {obj_path}")
1169
1170 # Sprawdzenie bibliotek
1171 for lib in version_data.get("libraries", []):
1172 if self._is_library_applicable(lib):
1173 if "downloads" in lib and "artifact" in lib["downloads"]:
1174 artifact = lib["downloads"]["artifact"]
1175 lib_path = Path(LIBRARIES_DIR) / artifact["path"]
1176 lib_sha1 = artifact.get("sha1")
1177 if not lib_path.exists() or (lib_sha1 and not self._validate_sha1(str(lib_path), lib_sha1)):
1178 logging.debug(f"Brak lub uszkodzona biblioteka: {lib_path}. Dodaję do kolejki.")
1179 queued_count += self._queue_download(artifact["url"], lib_path, "library", lib_sha1)
1180 else:
1181 logging.debug(f"Biblioteka istnieje i jest poprawna: {lib_path}")
1182
1183 # Sprawdzenie natives
1184 classifiers = lib["downloads"].get("classifiers", {})
1185 native_classifier_data = None
1186 current_os_key = sys.platform
1187 if current_os_key == "win32":
1188 current_os_key = "windows"
1189 elif current_os_key == "darwin":
1190 current_os_key = "macos"
1191 elif current_os_key.startswith("linux"):
1192 current_os_key = "linux"
1193 arch = '64' if sys.maxsize > 2**32 else '32'
1194 search_keys = [f"natives-{current_os_key}-{arch}", f"natives-{current_os_key}"]
1195 for key in search_keys:
1196 if key in classifiers:
1197 native_classifier_data = classifiers[key]
1198 logging.debug(f"Znaleziono klasyfikator natywny '{key}' dla biblioteki {lib.get('name', 'Nieznana')}")
1199 break
1200
1201 if native_classifier_data:
1202 native_url = native_classifier_data["url"]
1203 native_sha1 = native_classifier_data.get("sha1")
1204 lib_name = lib.get("name", "unknown_lib").replace(":", "_").replace(".", "_")
1205 classifier_file_name = Path(native_classifier_data["path"]).name
1206 native_zip_path = version_dir / "natives_zips" / f"{lib_name}_{classifier_file_name}"
1207 if not native_zip_path.exists() or (native_sha1 and not self._validate_sha1(str(native_zip_path), native_sha1)):
1208 logging.debug(f"Brak lub uszkodzony plik natives: {native_zip_path}. Dodaję do kolejki.")
1209 queued_count += self._queue_download(native_url, native_zip_path, "native zip", native_sha1)
1210 else:
1211 logging.debug(f"Plik natives istnieje i jest poprawny: {native_zip_path}")
1212
1213 logging.info(f"Zakończono sprawdzanie plików dla wersji {version_id}. Dodano do kolejki: {queued_count} plików.")
1214 return queued_count
1215
1216 def _validate_sha1(self, file_path, expected_sha1):
1217 """
1218 Weryfikuje sumę SHA1 pliku. Zwraca True, jeśli plik istnieje i suma jest poprawna.
1219 """
1220 if not expected_sha1 or not re.match(r'^[a-f0-9]{40}$', expected_sha1):
1221 logging.warning(f"SHA1 validation skipped for {Path(file_path).name}: Invalid or missing SHA1 hash.")
1222 return Path(file_path).exists()
1223
1224 try:
1225 sha1 = hashlib.sha1()
1226 with open(file_path, "rb") as f:
1227 for chunk in iter(lambda: f.read(8192), b""):
1228 sha1.update(chunk)
1229 calculated_sha1 = sha1.hexdigest()
1230 is_valid = calculated_sha1 == expected_sha1
1231 if not is_valid:
1232 logging.warning(f"SHA1 mismatch for {Path(file_path).name}. Expected: {expected_sha1}, Got: {calculated_sha1}")
1233 return is_valid
1234 except FileNotFoundError:
1235 logging.warning(f"SHA1 validation failed: file not found {file_path}")
1236 return False
1237 except Exception as e:
1238 logging.error(f"Error during SHA1 validation for {file_path}: {e}")
1239 return False
1240
1241 def extract_natives(self, instance_dir, version_id):
1242 """
1243 Rozpakowuje pliki JAR z natives_zips do folderu natives dla danej instancji.
1244 """
1245 natives_zips_dir = Path(instance_dir) / "versions" / version_id / "natives_zips"
1246 natives_dir = Path(instance_dir) / "versions" / version_id / "natives"
1247
1248 # Tworzenie folderu natives, jeśli nie istnieje
1249 natives_dir.mkdir(parents=True, exist_ok=True)
1250 logging.debug(f"Tworzenie folderu natives: {natives_dir}")
1251
1252 # Sprawdzenie, czy folder natives_zips istnieje
1253 if not natives_zips_dir.exists():
1254 logging.warning(f"Folder natives_zips nie istnieje: {natives_zips_dir}. Brak natives do rozpakowania.")
1255 return
1256
1257 # Pobieranie listy plików JAR w natives_zips
1258 jar_files = list(natives_zips_dir.glob("*.jar"))
1259 if not jar_files:
1260 logging.warning(f"Brak plików JAR w folderze natives_zips: {natives_zips_dir}")
1261 return
1262
1263 # Rozpakowywanie każdego pliku JAR
1264 for jar_file in jar_files:
1265 logging.debug(f"Rozpakowywanie pliku JAR: {jar_file}")
1266 try:
1267 with zipfile.ZipFile(jar_file, "r") as zip_ref:
1268 # Pobieranie listy plików w JAR, pomijając META-INF
1269 file_list = [f for f in zip_ref.namelist() if not f.startswith("META-INF/")]
1270 for file_name in file_list:
1271 # Rozpakowywanie pliku do natives
1272 zip_ref.extract(file_name, natives_dir)
1273 logging.debug(f"Rozpakowano plik: {file_name} do {natives_dir}")
1274 except zipfile.BadZipFile:
1275 logging.error(f"Uszkodzony plik JAR: {jar_file}. Pomijanie.")
1276 continue
1277 except Exception as e:
1278 logging.error(f"Błąd podczas rozpakowywania pliku JAR {jar_file}: {e}")
1279 continue
1280
1281 logging.info(f"Zakończono rozpakowywanie natives dla wersji {version_id} do {natives_dir}")
1282
1283 def launch_game(self, instance_dir_path, username):
1284 import uuid
1285 from PyQt6.QtWidgets import QDialog
1286
1287 instance_dir = Path(instance_dir_path)
1288 if not instance_dir.exists():
1289 raise FileNotFoundError(f"Katalog instancji nie istnieje: {instance_dir_path}")
1290
1291 instance_settings_path = instance_dir / "settings.json"
1292 if not instance_settings_path.exists():
1293 raise FileNotFoundError(f"Plik ustawień instancji nie znaleziono: {instance_settings_path}")
1294
1295 try:
1296 with instance_settings_path.open("r", encoding='utf-8') as f:
1297 instance_settings = json.load(f)
1298 except json.JSONDecodeError as e:
1299 logging.error(f"Błąd odczytu settings.json instancji {instance_dir}: {e}")
1300 raise ValueError(f"Nie udało się odczytać ustawień instancji: {e}")
1301
1302 version_id = instance_settings.get("version")
1303 if not version_id:
1304 raise ValueError("Instancja nie ma przypisanej wersji gry. Uruchom instalację instancji ponownie.")
1305
1306 # Sprawdzenie i pobieranie brakujących plików
1307 queued_count = self._validate_and_queue_missing_files(instance_dir, version_id)
1308 if queued_count > 0:
1309 logging.info(f"Znaleziono {queued_count} brakujących/uszkodzonych plików. Rozpoczynam pobieranie.")
1310 self.progress_dialog = DownloadProgressDialog(self, None)
1311 self.progress_dialog.set_total_files(queued_count)
1312 self.progress_dialog.cancel_signal.connect(self.cancel_downloads)
1313
1314 # Automatyczne zamknięcie dialogu po zakończeniu pobierania
1315 def on_download_finished():
1316 if self.progress_dialog.successful_files_count == queued_count:
1317 logging.info(f"Pobieranie zakończone sukcesem: {queued_count}/{queued_count} plików. Zamykam dialog.")
1318 self.progress_dialog.accept() # Zamyka dialog z kodem 1 (Accepted)
1319 else:
1320 logging.warning(f"Pobieranie nie powiodło się: {self.progress_dialog.successful_files_count}/{queued_count} plików.")
1321 self.progress_dialog.reject() # Zamyka dialog z kodem 0 (Rejected)
1322
1323 self.progress_dialog.download_process_finished.connect(on_download_finished)
1324 self.process_download_queue()
1325
1326 # Wykonanie dialogu i sprawdzenie wyniku
1327 result = self.progress_dialog.exec()
1328 logging.debug(f"Dialog zamknięty z wynikiem: {result}")
1329 if result != 1 or self.progress_dialog.is_cancelled: # 1 to QDialog.Accepted
1330 logging.warning("Pobieranie zostało anulowane lub nie powiodło się.")
1331 raise RuntimeError("Pobieranie brakujących plików nie powiodło się lub zostało anulowane.")
1332
1333 logging.debug("Dialog zamknięty pomyślnie, przechodzę do rozpakowania natives.")
1334 # Czyszczenie dialogu
1335 self.progress_dialog.deleteLater()
1336 self.progress_dialog = None
1337
1338 # Rozpakowanie natives po upewnieniu się, że wszystkie pliki są na miejscu
1339 self.extract_natives(instance_dir, version_id)
1340
1341 version_dir = instance_dir / "versions" / version_id
1342 version_json_path = version_dir / f"{version_id}.json"
1343 if not version_json_path.exists():
1344 raise FileNotFoundError(f"Plik wersji gry {version_json_path} nie istnieje. Uruchom instalację instancji ponownie.")
1345
1346 try:
1347 with version_json_path.open("r", encoding='utf-8') as f:
1348 version_data = json.load(f)
1349 except json.JSONDecodeError as e:
1350 logging.error(f"Błąd odczytu JSON wersji {version_id}: {e}")
1351 raise ValueError(f"Nie udało się odczytać danych wersji: {e}")
1352
1353 # --- Budowanie polecenia startowego ---
1354
1355 # 1. Java path
1356 java_path = self.find_java_for_version(version_id)
1357 if not java_path or not Path(java_path).exists():
1358 raise FileNotFoundError(f"Nie znaleziono kompatybilnej Javy dla wersji {version_id} lub ścieżka Javy jest niepoprawna.")
1359
1360 # 2. Classpath
1361 classpath = []
1362 modloader = instance_settings.get("modloader")
1363 launch_version_id = version_id
1364
1365 if modloader:
1366 modded_version_jsons = list((instance_dir / "versions").glob(f"{version_id}-*.json"))
1367 if modded_version_jsons:
1368 modded_version_jsons.sort(key=lambda p: p.stat().st_mtime, reverse=True)
1369 found_modded_json_path = modded_version_jsons[0]
1370 launch_version_id = found_modded_json_path.stem
1371 logging.info(f"Znaleziono modowany JSON wersji: {found_modded_json_path.name} (ID: {launch_version_id})")
1372 try:
1373 with found_modded_json_path.open("r", encoding='utf-8') as f:
1374 version_data = json.load(f)
1375 except json.JSONDecodeError as e:
1376 logging.error(f"Błąd odczytu modowanego JSON {found_modded_json_path}: {e}")
1377 raise ValueError(f"Nie udało się odczytać danych modowanej wersji: {e}")
1378
1379 main_jar_path = instance_dir / "versions" / launch_version_id / f"{launch_version_id}.jar"
1380 if main_jar_path.exists():
1381 classpath.append(str(main_jar_path))
1382 else:
1383 client_jar_candidate = list(version_dir.glob("*.jar"))
1384 if client_jar_candidate:
1385 main_jar_path = client_jar_candidate[0]
1386 classpath.append(str(main_jar_path))
1387 logging.warning(f"Główny JAR {launch_version_id}.jar nie istnieje, używam: {main_jar_path.name}")
1388 else:
1389 raise FileNotFoundError(f"Plik główny gry (.jar) nie istnieje: {main_jar_path}.")
1390
1391 for lib in version_data.get("libraries", []):
1392 if self._is_library_applicable(lib):
1393 if "downloads" in lib and "artifact" in lib["downloads"]:
1394 # Dodajemy tylko biblioteki bez klasyfikatorów natywnych
1395 if not lib.get("downloads", {}).get("classifiers"):
1396 artifact = lib["downloads"]["artifact"]
1397 lib_path = Path(LIBRARIES_DIR) / artifact["path"]
1398 if lib_path.exists():
1399 classpath.append(str(lib_path))
1400 else:
1401 logging.warning(f"Brak pliku biblioteki: {lib_path}")
1402 elif "name" in lib:
1403 # Zgadywanie tylko dla bibliotek bez natywnych klasyfikatorów
1404 if not lib.get("natives"):
1405 parts = lib["name"].split(':')
1406 if len(parts) >= 3:
1407 group = parts[0].replace('.', '/')
1408 artifact = parts[1]
1409 version = parts[2]
1410 guessed_path = Path(LIBRARIES_DIR) / group / artifact / version / f"{artifact}-{version}.jar"
1411 if guessed_path.exists():
1412 classpath.append(str(guessed_path))
1413 else:
1414 logging.warning(f"Brak zgadywanej biblioteki: {guessed_path}")
1415
1416 classpath_str = ";".join(classpath) if sys.platform == "win32" else ":".join(classpath)
1417
1418 # 3. JVM arguments
1419 jvm_args = []
1420 ram = instance_settings.get("ram", self.settings.get("ram", "4G"))
1421 jvm_args.extend([f"-Xmx{ram}", "-Xms512M"])
1422
1423 natives_dir = version_dir / "natives"
1424 if natives_dir.exists():
1425 jvm_args.append(f"-Djava.library.path={natives_dir}")
1426 else:
1427 logging.warning(f"Katalog natywnych bibliotek nie istnieje: {natives_dir}")
1428
1429 jvm_args_extra = instance_settings.get("jvm_args_extra", self.settings.get("jvm_args", ""))
1430 if jvm_args_extra:
1431 jvm_args.extend(jvm_args_extra.split())
1432
1433 # 4. Game arguments
1434 game_args = []
1435 main_class = version_data.get("mainClass", "net.minecraft.client.main.Main")
1436
1437 # Generowanie UUID dla trybu offline
1438 offline_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, username))
1439
1440 # Rozpoznanie typu wersji
1441 version_tuple, modern_args = parse_version_type(version_id)
1442
1443 # Argumenty w zależności od wersji
1444 if modern_args:
1445 game_args.extend([
1446 "--username", username,
1447 "--version", launch_version_id,
1448 "--gameDir", str(instance_dir),
1449 "--assetsDir", str(ASSETS_DIR),
1450 "--assetIndex", version_data.get("assetIndex", {}).get("id", "legacy"),
1451 "--uuid", offline_uuid,
1452 "--accessToken", "0",
1453 "--userType", "legacy",
1454 "--userProperties", "{}"
1455 ])
1456 else:
1457 game_args.extend([
1458 username,
1459 "0" # sessionId dla starszych wersji
1460 ])
1461
1462 # Rozdzielczość i pełny ekran
1463 resolution = instance_settings.get("resolution", self.settings.get("resolution", "1280x720"))
1464 if resolution and re.match(r"^\d+x\d+$", resolution):
1465 width, height = resolution.split('x')
1466 game_args.extend(["--width", width, "--height", height])
1467
1468 if instance_settings.get("fullscreen", self.settings.get("fullscreen", False)):
1469 game_args.append("--fullscreen")
1470
1471 # 5. Budowanie pełnego polecenia
1472 cmd = [java_path] + jvm_args + ["-cp", classpath_str, main_class] + game_args
1473 logging.info(f"Uruchamianie gry: {' '.join([str(c) for c in cmd])}")
1474
1475 try:
1476 process = subprocess.Popen(
1477 cmd,
1478 cwd=str(instance_dir),
1479 stdout=subprocess.PIPE,
1480 stderr=subprocess.PIPE,
1481 text=True
1482 )
1483 stdout, stderr = process.communicate(timeout=300)
1484 logging.info(f"Gra stdout:\n{stdout}")
1485 if stderr:
1486 logging.error(f"Gra stderr:\n{stderr}")
1487 if process.returncode != 0:
1488 raise subprocess.CalledProcessError(process.returncode, cmd, stdout, stderr)
1489
1490 logging.info(f"Gra uruchomiona pomyślnie (PID: {process.pid})")
1491
1492 except subprocess.TimeoutExpired:
1493 logging.error("Uruchamianie gry przekroczyło limit czasu.")
1494 raise TimeoutError("Uruchamianie gry przekroczyło limit czasu.")
1495 except subprocess.CalledProcessError as e:
1496 logging.error(f"Błąd uruchamiania gry: Kod {e.returncode}, stderr: {e.stderr}")
1497 raise ValueError(f"Błąd uruchamiania gry: {e.stderr}")
1498 except Exception as e:
1499 logging.error(f"Nieoczekiwany błąd uruchamiania gry: {e}")
1500 raise RuntimeError(f"Nieoczekiwany błąd: {e}")
1501
1502
1503 def _is_library_applicable(self, library_data):
1504 rules = library_data.get('rules')
1505 if not rules:
1506 return True
1507
1508 current_os_name = sys.platform
1509 if current_os_name == "win32":
1510 current_os_name = "windows"
1511 elif current_os_name == "darwin":
1512 current_os_name = "osx"
1513
1514 for rule in rules:
1515 action = rule.get('action')
1516 os_info = rule.get('os', {})
1517 rule_os_name = os_info.get('name')
1518
1519 rule_applies_to_current_os = False
1520 if rule_os_name is None:
1521 rule_applies_to_current_os = True
1522 elif rule_os_name == current_os_name:
1523 rule_applies_to_current_os = True
1524
1525 if rule_applies_to_current_os:
1526 if action == 'disallow':
1527 logging.debug(f"Library rule disallowed: {library_data.get('name', 'Unknown')}")
1528 return False
1529
1530 return True
1531
1532 def _is_argument_applicable(self, arg_data):
1533 rules = arg_data.get('rules')
1534 if not rules:
1535 return True
1536
1537 current_os_name = sys.platform
1538 if current_os_name == "win32":
1539 current_os_name = "windows"
1540 elif current_os_name == "darwin":
1541 current_os_name = "osx"
1542
1543 disallows_rule_applies = False
1544
1545 for rule in rules:
1546 action = rule.get('action')
1547 os_info = rule.get('os', {})
1548 rule_os_name = os_info.get('name')
1549
1550 rule_applies_to_current_os = False
1551 if rule_os_name is None:
1552 rule_applies_to_current_os = True
1553 elif rule_os_name == current_os_name:
1554 rule_applies_to_current_os = True
1555
1556 if rule_applies_to_current_os:
1557 if action == 'disallow':
1558 disallows_rule_applies = True
1559 break
1560
1561 if disallows_rule_applies:
1562 return False
1563
1564 return True
1565
1566 def find_java(self):
1567 return self.java_versions[0][0] if self.java_versions else None
1568
1569 def find_java_versions(self):
1570 java_versions = []
1571 checked_paths = set()
1572
1573 try:
1574 java_path = "java"
1575 find_cmd = ["where", "java"] if sys.platform == "win32" else ["which", "java"]
1576 process = subprocess.run(find_cmd, capture_output=True, text=True, timeout=5, check=True)
1577 path_output = process.stdout.strip().splitlines()
1578 if path_output:
1579 resolved_path = path_output[0]
1580 if Path(resolved_path).is_file():
1581 java_path = resolved_path
1582
1583 result = subprocess.run([java_path, "-version"], capture_output=True, text=True, timeout=5, check=True)
1584 version_line = result.stderr.splitlines()[0] if result.stderr else ""
1585 if java_path not in checked_paths:
1586 java_versions.append((java_path, f"System Java ({version_line.strip()})"))
1587 checked_paths.add(java_path)
1588 except (subprocess.CalledProcessError, FileNotFoundError, TimeoutError, Exception) as e:
1589 logging.debug(f"System 'java' not found or error: {e}")
1590
1591 if sys.platform == "win32":
1592 program_files = Path(os.environ.get("ProgramFiles", "C:/Program Files"))
1593 program_files_x86 = Path(os.environ.get("ProgramFiles(x86)", "C:/Program Files (x86)"))
1594 java_install_dirs = [
1595 program_files / "Java",
1596 program_files_x86 / "Java",
1597 JAVA_DIR
1598 ]
1599
1600 for base_dir in java_install_dirs:
1601 if not base_dir.exists():
1602 continue
1603 scan_dirs = [base_dir]
1604 try:
1605 for level1 in base_dir.iterdir():
1606 if level1.is_dir():
1607 scan_dirs.append(level1)
1608 try:
1609 for level2 in level1.iterdir():
1610 if level2.is_dir():
1611 scan_dirs.append(level2)
1612 except Exception as e:
1613 logging.debug(f"Error scanning subdir {level1}: {e}")
1614 except Exception as e:
1615 logging.debug(f"Error scanning base dir {base_dir}: {e}")
1616
1617
1618 for java_dir in scan_dirs:
1619 if java_dir.is_dir():
1620 java_exe = java_dir / "bin" / "java.exe"
1621 if java_exe.exists() and str(java_exe) not in checked_paths:
1622 try:
1623 result = subprocess.run([str(java_exe), "-version"], capture_output=True, text=True, timeout=5, check=True)
1624 version_line = result.stderr.splitlines()[0] if result.stderr else ""
1625 display_name = java_dir.relative_to(base_dir) if java_dir.is_relative_to(base_dir) else java_dir.name
1626 java_versions.append((str(java_exe), f"{display_name} ({version_line.strip()})"))
1627 checked_paths.add(str(java_exe))
1628 except (subprocess.CalledProcessError, FileNotFoundError, TimeoutError, Exception) as e:
1629 logging.debug(f"Error getting version for {java_exe}: {e}")
1630
1631 elif sys.platform == "darwin":
1632 java_install_dirs = [
1633 Path("/Library/Java/JavaVirtualMachines"),
1634 Path("/usr/local/Cellar"),
1635 Path.home() / ".sdkman" / "candidates" / "java",
1636 JAVA_DIR
1637 ]
1638 for base_dir in java_install_dirs:
1639 if not base_dir.exists():
1640 continue
1641 try:
1642 for java_dir in base_dir.iterdir():
1643 if java_dir.is_dir():
1644 java_exe = java_dir / "Contents" / "Home" / "bin" / "java"
1645 if java_exe.exists() and str(java_exe) not in checked_paths:
1646 try:
1647 result = subprocess.run([str(java_exe), "-version"], capture_output=True, text=True, timeout=5, check=True)
1648 version_line = result.stderr.splitlines()[0] if result.stderr else ""
1649 display_name = java_dir.name
1650 java_versions.append((str(java_exe), f"{display_name} ({version_line.strip()})"))
1651 checked_paths.add(str(java_exe))
1652 except Exception as e:
1653 logging.debug(f"Error getting version for {java_exe}: {e}")
1654 except Exception as e:
1655 logging.debug(f"Error scanning base dir {base_dir}: {e}")
1656
1657 elif sys.platform.startswith("linux"):
1658 java_install_dirs = [
1659 Path("/usr/lib/jvm"),
1660 Path("/opt/java"),
1661 Path.home() / ".sdkman" / "candidates" / "java",
1662 JAVA_DIR
1663 ]
1664 for base_dir in java_install_dirs:
1665 if not base_dir.exists():
1666 continue
1667 try:
1668 for java_dir in base_dir.iterdir():
1669 if java_dir.is_dir():
1670 java_exe = java_dir / "bin" / "java"
1671 if java_exe.exists() and str(java_exe) not in checked_paths:
1672 try:
1673 result = subprocess.run([str(java_exe), "-version"], capture_output=True, text=True, timeout=5, check=True)
1674 version_line = result.stderr.splitlines()[0] if result.stderr else ""
1675 display_name = java_dir.name
1676 java_versions.append((str(java_exe), f"{display_name} ({version_line.strip()})"))
1677 checked_paths.add(str(java_exe))
1678 except Exception as e:
1679 logging.debug(f"Error getting version for {java_exe}: {e}")
1680 except Exception as e:
1681 logging.debug(f"Error scanning base dir {base_dir}: {e}")
1682
1683
1684 logging.info(f"Znaleziono wersje Javy: {java_versions}")
1685 return java_versions
1686
1687 def get_java_version_from_path(self, java_path):
1688 if not java_path or not Path(java_path).exists():
1689 return None
1690 try:
1691 result = subprocess.run([java_path, "-version"], capture_output=True, text=True, timeout=5, check=True)
1692 version_str = result.stderr.splitlines()[0] if result.stderr else ""
1693 match = re.search(r"(?:openjdk|java) version \"(\d+)(?:\.(\d+))?(?:\.(\d+))?", version_str)
1694 if match:
1695 major = int(match.group(1))
1696 if major == 1 and match.group(2) is not None:
1697 return int(match.group(2))
1698 return major
1699 match = re.search(r"openjdk (\d+)(?:\.(\d+))?", version_str)
1700 if match:
1701 return int(match.group(1))
1702 logging.warning(f"Nie można sparsować wersji Javy z: {version_str} dla {java_path}")
1703 return None
1704 except (subprocess.CalledProcessError, FileNotFoundError, TimeoutError, Exception) as e:
1705 logging.error(f"Błąd podczas odczytu wersji Javy z {java_path}: {e}")
1706 return None
1707
1708 def get_required_java_version(self, version_id):
1709 logging.debug(f"Sprawdzam wymaganą wersję Javy dla {version_id}")
1710
1711 try:
1712 manifest = self.get_version_manifest()
1713 version_info_from_manifest = next((v for v in manifest.get("versions", []) if v["id"] == version_id), None)
1714 if version_info_from_manifest:
1715 version_json_url = version_info_from_manifest.get("url")
1716 if version_json_url:
1717 response = requests.get(version_json_url, timeout=10)
1718 response.raise_for_status()
1719 version_data = response.json()
1720 required_java_from_json = version_data.get("javaVersion", {}).get("majorVersion")
1721 if required_java_from_json:
1722 logging.debug(f"Wymagana Java z Version JSON dla {version_id}: {required_java_from_json}")
1723 return required_java_from_json
1724 else:
1725 logging.debug(f"Version JSON dla {version_id} nie zawiera 'javaVersion'.")
1726 else:
1727 logging.warning(f"Manifest wersji dla {version_id} nie zawiera URL do version JSON.")
1728 else:
1729 logging.warning(f"Wersja {version_id} nie znaleziona w manifeście wersji.")
1730 except Exception as e:
1731 logging.debug(f"Nie udało się pobrać/sparsować version JSON dla {version_id} w celu sprawdzenia Javy: {e}. Używam domyślnej logiki.")
1732
1733
1734 try:
1735 parts = version_id.split('.')
1736 if re.match(r"^\d+w\d+[a-z]$", version_id):
1737 logging.debug(f"'{version_id}' to snapshot, szacuję wymaganą Javę.")
1738 try:
1739 year_week_match = re.match(r"^(\d+)w(\d+)", version_id)
1740 if year_week_match:
1741 year = int(year_week_match.group(1))
1742 week = int(year_week_match.group(2))
1743 if year >= 24:
1744 return 21
1745 elif year == 23 and week >= 14:
1746 return 17
1747 return 17
1748 except Exception as e:
1749 logging.warning(f"Błąd parsowania daty snapshota '{version_id}': {e}. Domyślnie Java 17.")
1750 return 17
1751
1752 if len(parts) >= 2:
1753 major = int(parts[0])
1754 minor = int(parts[1])
1755
1756 if major >= 2:
1757 return 21
1758 if major == 1:
1759 if minor >= 21:
1760 return 21
1761 elif minor == 20 and (len(parts) < 3 or int(parts[2]) >= 5):
1762 return 21
1763 elif minor >= 18:
1764 return 17
1765 elif minor >= 17:
1766 return 16
1767 elif minor >= 13:
1768 return 8
1769 else:
1770 return 8
1771 logging.warning(f"Nieobsługiwany format wersji gry '{version_id}' dla wymaganej Javy. Domyślnie Java 8.")
1772 return 8
1773
1774 except Exception as e:
1775 logging.error(f"Nieoczekiwany błąd podczas określania wymaganej Javy dla '{version_id}': {e}. Domyślnie Java 8.")
1776 return 8
1777
1778
1779 def find_java_for_version(self, version_id):
1780 """
1781 Znajduje odpowiednią wersję Javy dla danej wersji Minecrafta.
1782 """
1783 # Mapowanie wymagań Javy
1784 java_requirements = {
1785 (1, 0): 8, # Wersje 1.0-1.16.5 -> Java 8
1786 (1, 17): 17, # Wersje 1.17-1.20 -> Java 17
1787 (1, 21): 21, # Wersje 1.21+ i snapshoty -> Java 21
1788 }
1789
1790 # Rozpoznanie typu wersji
1791 version_tuple, _ = parse_version_type(version_id)
1792 required_java = 8 # Domyślnie Java 8
1793
1794 # Snapshoty z 2025 zakładamy jako >= 1.21
1795 if "w" in version_id:
1796 required_java = 21
1797 else:
1798 for (major, minor), java_ver in java_requirements.items():
1799 if version_tuple >= (major, minor):
1800 required_java = java_ver
1801
1802 logging.info(f"Wersja {version_id} wymaga Javy {required_java}+")
1803
1804 # Szukanie Javy
1805 possible_java_paths = [
1806 shutil.which("java"),
1807 r"C:\Program Files\Java\jdk-{}\bin\java.exe".format(required_java),
1808 r"C:\Program Files\Java\jre-{}\bin\java.exe".format(required_java),
1809 r"C:\Program Files\AdoptOpenJDK\jdk-{}-hotspot\bin\java.exe".format(required_java),
1810 r"/usr/lib/jvm/java-{}-openjdk/bin/java".format(required_java),
1811 r"/usr/lib/jvm/java-{}-openjdk-amd64/bin/java".format(required_java),
1812 ]
1813
1814 for path in possible_java_paths:
1815 if path and Path(path).exists():
1816 try:
1817 result = subprocess.run(
1818 [path, "-version"],
1819 capture_output=True,
1820 text=True,
1821 check=True
1822 )
1823 version_match = re.search(r'version "(\d+)(?:\.(\d+))?', result.stderr)
1824 if version_match:
1825 java_major = int(version_match.group(1))
1826 if java_major >= required_java:
1827 logging.info(f"Znaleziono kompatybilną Javę ({java_major}): {path}")
1828 return path
1829 else:
1830 logging.warning(f"Java {java_major} w {path} jest za stara, wymagana {required_java}")
1831 except (subprocess.CalledProcessError, FileNotFoundError):
1832 logging.warning(f"Ścieżka Javy {path} jest nieprawidłowa lub nie działa")
1833
1834 # Fallback na dowolną Javę
1835 logging.warning(f"Nie znaleziono Javy {required_java}+, próbuję dowolnej wersji")
1836 for path in possible_java_paths:
1837 if path and Path(path).exists():
1838 logging.info(f"Używam fallback Javy: {path}")
1839 return path
1840
1841 logging.error(f"Nie znaleziono żadnej wersji Javy dla wersji {version_id}")
1842 return None
1843
1844
1845 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):
1846 if not name:
1847 raise ValueError("Nazwa instancji nie może być pusta!")
1848 if not version_id:
1849 raise ValueError("Nie wybrano wersji Minecrafta!")
1850
1851 if not re.match(r"^[a-zA-Z0-9._-]+$", version_id):
1852 raise ValueError(f"Nieprawidłowy format ID wersji: '{version_id}'.")
1853
1854 if modloader and modloader.lower() not in ["forge", "neoforge", "fabric", "quilt"]:
1855 raise ValueError(f"Nieznany typ modloadera: '{modloader}'. Obsługiwane: Forge, NeoForge, Fabric, Quilt.")
1856
1857 safe_name = re.sub(r'[<>:"/\\|?*]', '_', name)
1858 safe_name = safe_name.strip()
1859 if not safe_name:
1860 raise ValueError("Nazwa instancji po usunięciu nieprawidłowych znaków jest pusta.")
1861
1862 if base_instance_dir_input and Path(base_instance_dir_input) != INSTANCES_DIR:
1863 instance_dir = Path(base_instance_dir_input)
1864 instance_dir = instance_dir / safe_name
1865
1866 try:
1867 resolved_instance_dir = instance_dir.resolve()
1868 resolved_instances_dir = INSTANCES_DIR.resolve()
1869 if resolved_instances_dir in resolved_instance_dir.parents or resolved_instance_dir == resolved_instances_dir:
1870 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.")
1871 except ValueError:
1872 raise
1873 except Exception as e:
1874 logging.error(f"Błąd walidacji ścieżki instancji: {e}")
1875 QMessageBox.critical(parent_window, "Błąd folderu instancji", f"Wystąpił błąd podczas walidacji ścieżki instancji: {e}")
1876 raise
1877
1878 else:
1879 instance_dir = INSTANCES_DIR / safe_name
1880
1881 if instance_dir.exists():
1882 is_empty = not any(instance_dir.iterdir())
1883 if not is_empty:
1884 raise FileExistsError(f"Katalog docelowy instancji '{instance_dir}' już istnieje i nie jest pusty! Wybierz inną nazwę lub folder.")
1885
1886 instance_dir.mkdir(parents=True, exist_ok=True)
1887
1888 if self.current_download_thread or self.download_queue:
1889 QMessageBox.warning(parent_window, "Pobieranie aktywne", "Inny proces pobierania jest aktywny. Spróbuj ponownie później.")
1890 return
1891
1892 try:
1893 logging.info(f"Przygotowanie do pobierania plików dla instancji '{name}' ({version_id})")
1894 self.download_queue.clear()
1895
1896 queued_version_files_count = self._queue_version_files(version_id, instance_dir)
1897
1898 queued_modloader_files_count = 0
1899 if modloader:
1900 if not self.validate_modloader(modloader, version_id):
1901 raise ValueError(f"{modloader.capitalize()} prawdopodobnie nie wspiera wersji {version_id}. Wybierz inną wersję gry lub modloader.")
1902
1903 queued_modloader_files_count = self._queue_modloader_installer(modloader, version_id, instance_dir)
1904
1905 total_queued_for_download = len(self.download_queue)
1906
1907 if total_queued_for_download == 0:
1908 logging.info("Wszystkie pliki do pobrania już istnieją lub nie wymagają pobierania. Przechodzę do konfiguracji.")
1909 self._extract_natives(version_id, instance_dir)
1910 if modloader:
1911 self._run_modloader_installer(modloader, version_id, instance_dir)
1912 self.save_instance_settings(instance_dir, name, version_id, modloader, ram, java_path_setting, jvm_args_extra)
1913 logging.info(f"Instancja '{name}' ({version_id}) stworzona pomyślnie w {instance_dir}")
1914 QMessageBox.information(parent_window, "Sukces", f"Instancja '{name}' stworzona pomyślnie!")
1915 if hasattr(parent_window, 'update_instance_tiles'):
1916 parent_window.update_instance_tiles()
1917 return str(instance_dir)
1918
1919 logging.info(f"Kolejka pobierania gotowa. Plików do pobrania: {total_queued_for_download}")
1920 self.progress_dialog = DownloadProgressDialog(self, parent_window) # Pass launcher to dialog
1921 self.progress_dialog.set_total_files(total_queued_for_download)
1922 self.progress_dialog.cancel_signal.connect(self.cancel_downloads)
1923 self.progress_dialog.download_process_finished.connect(self._handle_create_instance_post_download)
1924
1925 self._post_download_data = {
1926 "instance_dir": str(instance_dir),
1927 "name": name,
1928 "version_id": version_id,
1929 "modloader": modloader,
1930 "ram": ram,
1931 "java_path_setting": java_path_setting,
1932 "jvm_args_extra": jvm_args_extra,
1933 "parent_window": parent_window
1934 }
1935
1936 self.process_download_queue()
1937 self.progress_dialog.exec()
1938
1939 return str(instance_dir)
1940
1941
1942 except (ValueError, FileExistsError, FileNotFoundError, ConnectionError, PermissionError, Exception) as e:
1943 self.download_queue.clear()
1944 if self.current_download_thread:
1945 self.current_download_thread.cancel()
1946 self.current_download_thread.wait(2000)
1947 self.current_download_thread = None
1948 if self.progress_dialog:
1949 self.progress_dialog.reject()
1950 self.progress_dialog = None
1951
1952 if instance_dir.exists():
1953 try:
1954 if not any(instance_dir.iterdir()):
1955 logging.debug(f"Usuwanie pustego katalogu instancji po błędzie: {instance_dir}")
1956 instance_dir.rmdir()
1957 else:
1958 logging.debug(f"Katalog instancji {instance_dir} nie jest pusty, nie usuwam go po błędzie.")
1959 except Exception as cleanup_e:
1960 logging.warning(f"Nie udało się posprzątać katalogu instancji {instance_dir} po błędzie: {cleanup_e}")
1961
1962 logging.error(f"Błąd podczas przygotowania instancji: {e}")
1963 raise
1964
1965
1966 def _handle_create_instance_post_download(self, success):
1967 if self.progress_dialog:
1968 post_data = self._post_download_data
1969 QTimer.singleShot(0, self.progress_dialog.deleteLater)
1970 self.progress_dialog = None
1971
1972 if post_data is None:
1973 logging.error("Brak danych do konfiguracji po pobraniu. Nie mogę zakończyć tworzenia instancji.")
1974 parent_window = QApplication.activeWindow()
1975 QMessageBox.critical(parent_window, "Błąd konfiguracji instancji", "Wystąpił wewnętrzny błąd po pobraniu. Spróbuj ponownie.")
1976 return
1977
1978 instance_dir = Path(post_data["instance_dir"])
1979 name = post_data["name"]
1980 version_id = post_data["version_id"]
1981 modloader = post_data["modloader"]
1982 ram = post_data["ram"]
1983 java_path_setting = post_data["java_path_setting"]
1984 jvm_args_extra = post_data["jvm_args_extra"]
1985 parent_window = post_data.get("parent_window")
1986
1987 self._post_download_data = None
1988
1989 if not success:
1990 logging.warning("Tworzenie instancji anulowane lub zakończone z błędami pobierania.")
1991 QMessageBox.warning(parent_window, "Tworzenie instancji anulowane", "Tworzenie instancji zostało anulowane lub napotkało błędy podczas pobierania. Sprawdź logi.")
1992 return
1993
1994 logging.info("Pobieranie dla instancji zakończone pomyślnie. Kontynuuję konfigurację...")
1995
1996 try:
1997 self._extract_natives(version_id, instance_dir)
1998 if modloader:
1999 self._run_modloader_installer(modloader, version_id, instance_dir)
2000 self.save_instance_settings(instance_dir, name, version_id, modloader, ram, java_path_setting, jvm_args_extra)
2001
2002 logging.info(f"Instancja '{name}' ({version_id}) stworzona pomyślnie w {instance_dir}")
2003 QMessageBox.information(parent_window, "Sukces", f"Instancja '{name}' stworzona pomyślnie!")
2004
2005 if hasattr(parent_window, 'update_instance_tiles'):
2006 parent_window.update_instance_tiles()
2007
2008 except (ValueError, FileNotFoundError, RuntimeError, TimeoutError, IOError, Exception) as e:
2009 logging.error(f"Błąd podczas konfiguracji instancji po pobraniu: {e}")
2010 QMessageBox.critical(parent_window, "Błąd konfiguracji instancji", f"Nie udało się zakończyć konfiguracji instancji: {e}")
2011
2012 else:
2013 logging.error("Progress dialog finished signal received, but progress_dialog object was None.")
2014
2015
2016 def save_instance_settings(self, instance_dir, name, version_id, modloader, ram, java_path_setting, jvm_args_extra):
2017 instance_settings_path = instance_dir / "settings.json"
2018 settings_to_save = {
2019 "name": name,
2020 "version": version_id,
2021 "modloader": modloader,
2022 "ram": ram,
2023 "java_path": java_path_setting if java_path_setting is not None else "auto",
2024 "jvm_args": jvm_args_extra or DEFAULT_SETTINGS["jvm_args"],
2025 }
2026
2027 if instance_settings_path.exists():
2028 try:
2029 with instance_settings_path.open("r", encoding='utf-8') as f:
2030 existing_settings = json.load(f)
2031 existing_settings.update(settings_to_save)
2032 settings_to_save = existing_settings
2033 except json.JSONDecodeError:
2034 logging.warning(f"Nieprawidłowy format settings.json w {instance_dir}, nadpisuję nowymi standardowymi polami.")
2035 except Exception as e:
2036 logging.warning(f"Błąd odczytu istniejącego settings.json w {instance_dir}: {e}, nadpisuję nowymi standardowymi polami.")
2037
2038 try:
2039 with instance_settings_path.open("w", encoding='utf-8') as f:
2040 json.dump(settings_to_save, f, indent=4)
2041 logging.info(f"Zapisano ustawienia instancji do {instance_settings_path}")
2042 except Exception as e:
2043 logging.error(f"Błąd zapisu settings.json instancji {instance_dir}: {e}")
2044 raise IOError(f"Nie udało się zapisać ustawień instancji: {e}")
2045
2046
2047 def get_instance_list(self):
2048 valid_instances = []
2049 if not INSTANCES_DIR.exists():
2050 return []
2051 for item in INSTANCES_DIR.iterdir():
2052 settings_path = item / "settings.json"
2053 if item.is_dir() and settings_path.exists():
2054 instance_name = item.name
2055 instance_dir_path = str(item)
2056 try:
2057 with settings_path.open("r", encoding='utf-8') as f:
2058 settings = json.load(f)
2059 stored_name = settings.get("name")
2060 if stored_name and stored_name.strip():
2061 instance_name = stored_name.strip()
2062
2063 except (json.JSONDecodeError, Exception) as e:
2064 logging.warning(f"Błąd odczytu nazwy instancji z {settings_path}: {e}. Używam nazwy folderu: {item.name}")
2065
2066 valid_instances.append((instance_name, instance_dir_path))
2067
2068 valid_instances.sort(key=lambda x: x[0].lower())
2069
2070 return valid_instances
2071
2072
2073 def export_instance(self, instance_dir_path, zip_path):
2074 instance_dir = Path(instance_dir_path)
2075 zip_path = Path(zip_path)
2076 if not instance_dir.exists():
2077 raise FileNotFoundError(f"Katalog instancji nie istnieje: {instance_dir_path}")
2078 logging.info(f"Eksportowanie instancji z {instance_dir} do {zip_path}")
2079
2080 zip_path.parent.mkdir(parents=True, exist_ok=True)
2081
2082 try:
2083 with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
2084 for root, _, files in os.walk(instance_dir):
2085 for file in files:
2086 file_path = Path(root) / file
2087 archive_path = file_path.relative_to(instance_dir)
2088 if "natives_zips" not in archive_path.parts:
2089 zipf.write(file_path, archive_path)
2090 logging.info("Eksport zakończony pomyślnie.")
2091 except Exception as e:
2092 logging.error(f"Błąd eksportu instancji {instance_dir}: {e}")
2093 if zip_path.exists():
2094 try:
2095 zip_path.unlink()
2096 logging.warning(f"Usunięto częściowo utworzony plik zip: {zip_path}")
2097 except Exception as cleanup_e:
2098 logging.warning(f"Nie udało się usunąć częściowego pliku zip {zip_path}: {cleanup_e}")
2099
2100 raise IOError(f"Nie udało się wyeksportować instancji: {e}")
2101
2102 def import_instance(self, zip_path):
2103 zip_path = Path(zip_path)
2104 if not zip_path.exists():
2105 raise FileNotFoundError(f"Plik ZIP instancji nie istnieje: {zip_path}")
2106 if not zipfile.is_zipfile(zip_path):
2107 raise zipfile.BadZipFile(f"Plik '{zip_path.name}' nie jest prawidłowym plikiem ZIP.")
2108
2109 INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
2110
2111 try:
2112 instance_name_base = zip_path.stem
2113 safe_name_base = re.sub(r'[<>:"/\\|?*]', '_', instance_name_base)
2114 if not safe_name_base:
2115 safe_name_base = "imported_instance"
2116
2117 instance_dir = INSTANCES_DIR / safe_name_base
2118 counter = 1
2119 while instance_dir.exists():
2120 instance_dir = INSTANCES_DIR / f"{safe_name_base}-{counter}"
2121 counter += 1
2122
2123 logging.info(f"Importowanie instancji z {zip_path} do {instance_dir}")
2124 instance_dir.mkdir(parents=True)
2125
2126 with zipfile.ZipFile(zip_path, "r") as zipf:
2127 zipf.extractall(instance_dir)
2128
2129 settings_path = instance_dir / "settings.json"
2130 if settings_path.exists():
2131 try:
2132 with settings_path.open("r", encoding='utf-8') as f:
2133 settings = json.load(f)
2134 settings['name'] = instance_dir.name
2135 with settings_path.open("w", encoding='utf-8') as f:
2136 json.dump(settings, f, indent=4)
2137 logging.debug(f"Zaktualizowano nazwę w settings.json importowanej instancji do: {instance_dir.name}")
2138 except (json.JSONDecodeError, Exception) as e:
2139 logging.warning(f"Nie udało się zaktualizować nazwy w settings.json importowanej instancji {instance_dir.name}: {e}")
2140
2141 logging.info("Import zakończony pomyślnie.")
2142 return str(instance_dir)
2143
2144 except (zipfile.BadZipFile, FileNotFoundError, Exception) as e:
2145 logging.error(f"Błąd importu instancji z {zip_path}: {e}")
2146 if 'instance_dir' in locals() and instance_dir.exists():
2147 try:
2148 shutil.rmtree(instance_dir)
2149 logging.info(f"Usunięto częściowo zaimportowany katalog: {instance_dir}")
2150 except Exception as cleanup_e:
2151 logging.error(f"Błąd podczas czyszczenia katalogu {instance_dir}: {cleanup_e}")
2152
2153 if isinstance(e, (zipfile.BadZipFile, FileNotFoundError)):
2154 raise e
2155 else:
2156 raise ValueError(f"Nie udało się zaimportować instancji: {e}")
2157
2158
2159class CreateInstanceDialog(QDialog):
2160 def __init__(self, launcher, parent=None):
2161 super().__init__(parent)
2162 self.launcher = launcher
2163 self.setWindowTitle("Nowa instancja")
2164 self.setMinimumWidth(400)
2165 self.init_ui()
2166 self.version_combo.currentTextChanged.connect(self.update_modloaders)
2167 self.populate_versions()
2168 self.update_modloaders()
2169
2170 def init_ui(self):
2171 layout = QVBoxLayout(self)
2172 layout.setSpacing(10)
2173
2174 self.name_input = QLineEdit()
2175 self.name_input.setPlaceholderText("Nazwa instancji (np. MojaWersja)")
2176 layout.addWidget(QLabel("Nazwa instancji:"))
2177 layout.addWidget(self.name_input)
2178
2179 instance_dir_layout = QHBoxLayout()
2180 self.instance_dir_input = QLineEdit(str(INSTANCES_DIR))
2181 self.instance_dir_input.setReadOnly(True)
2182 self.instance_dir_button = QPushButton("Wybierz inny folder docelowy...")
2183 self.instance_dir_button.clicked.connect(self.choose_instance_dir)
2184 self.use_custom_dir_check = QCheckBox("Użyj innego folderu")
2185 self.use_custom_dir_check.setChecked(False)
2186 self.use_custom_dir_check.stateChanged.connect(self.toggle_custom_dir_input)
2187 instance_dir_layout.addWidget(self.instance_dir_input)
2188 instance_dir_layout.addWidget(self.instance_dir_button)
2189 self.instance_dir_input.setEnabled(False)
2190 self.instance_dir_button.setEnabled(False)
2191
2192 layout.addWidget(QLabel("Folder docelowy instancji:"))
2193 layout.addWidget(self.use_custom_dir_check)
2194 layout.addLayout(instance_dir_layout)
2195
2196 self.version_combo = QComboBox()
2197 layout.addWidget(QLabel("Wersja Minecrafta:"))
2198 layout.addWidget(self.version_combo)
2199
2200 self.modloader_combo = QComboBox()
2201 layout.addWidget(QLabel("Modloader (dla wybranych wersji):"))
2202 layout.addWidget(self.modloader_combo)
2203
2204 advanced_group = QWidget()
2205 advanced_layout = QVBoxLayout(advanced_group)
2206 advanced_layout.setContentsMargins(0, 0, 0, 0)
2207
2208 self.ram_input = QLineEdit(self.launcher.settings.get("ram", DEFAULT_SETTINGS["ram"]))
2209 advanced_layout.addWidget(QLabel("Maksymalna pamięć RAM (np. 4G, 2048M):"))
2210 advanced_layout.addWidget(self.ram_input)
2211
2212 self.jvm_args_input = QLineEdit(self.launcher.settings.get("jvm_args", DEFAULT_SETTINGS["jvm_args"]))
2213 advanced_layout.addWidget(QLabel("Dodatkowe argumenty JVM:"))
2214 advanced_layout.addWidget(self.jvm_args_input)
2215
2216 self.java_combo = QComboBox()
2217 self.java_combo.addItem("Automatyczny wybór", userData="auto")
2218 sorted_java_versions = sorted(self.launcher.java_versions, key=lambda x: self.launcher.get_java_version_from_path(x[0]) or 0, reverse=True)
2219 for java_path, version in sorted_java_versions:
2220 major_v = self.launcher.get_java_version_from_path(java_path)
2221 self.java_combo.addItem(f"{version} (Java {major_v}) - {java_path}", userData=java_path)
2222
2223 default_java_setting = self.launcher.settings.get("java_path")
2224 if default_java_setting and default_java_setting.lower() != 'auto':
2225 default_index = self.java_combo.findData(default_java_setting)
2226 if default_index != -1:
2227 self.java_combo.setCurrentIndex(default_index)
2228 else:
2229 custom_item_text = f"Zapisana ścieżka: {default_java_setting} (Nieznana wersja)"
2230 self.java_combo.addItem(custom_item_text, userData=default_java_setting)
2231 self.java_combo.setCurrentIndex(self.java_combo.count() - 1)
2232 logging.warning(f"Zapisana ścieżka Javy w ustawieniach nie znaleziona wśród automatycznie wykrytych: {default_java_setting}. Dodano jako opcję niestandardową.")
2233
2234 layout.addWidget(QLabel("Wersja Javy (zalecany 'Automatyczny wybór'):"))
2235 layout.addWidget(self.java_combo)
2236
2237 layout.addWidget(advanced_group)
2238
2239 button_layout = QHBoxLayout()
2240 create_button = QPushButton("Stwórz instancję")
2241 create_button.clicked.connect(self.check_and_accept)
2242 cancel_button = QPushButton("Anuluj")
2243 cancel_button.clicked.connect(self.reject)
2244 button_layout.addStretch(1)
2245 button_layout.addWidget(create_button)
2246 button_layout.addWidget(cancel_button)
2247 layout.addLayout(button_layout)
2248
2249 def toggle_custom_dir_input(self, state):
2250 enabled = self.use_custom_dir_check.isChecked()
2251 self.instance_dir_input.setEnabled(enabled)
2252 self.instance_dir_button.setEnabled(enabled)
2253 if not enabled:
2254 self.instance_dir_input.setText(str(INSTANCES_DIR))
2255
2256 def choose_instance_dir(self):
2257 current_dir = self.instance_dir_input.text()
2258 if not Path(current_dir).exists():
2259 current_dir = str(INSTANCES_DIR.parent)
2260
2261 folder = QFileDialog.getExistingDirectory(self, "Wybierz folder docelowy dla instancji", current_dir)
2262 if folder:
2263 self.instance_dir_input.setText(folder)
2264
2265 def populate_versions(self):
2266 self.version_combo.blockSignals(True)
2267 self.version_combo.clear()
2268 try:
2269 manifest = self.launcher.get_version_manifest()
2270 versions = sorted(manifest.get("versions", []), key=lambda x: x.get('releaseTime', '1970-01-01T00:00:00+00:00'), reverse=True)
2271
2272 for version in versions:
2273 self.version_combo.addItem(version["id"])
2274
2275 except ConnectionError as e:
2276 QMessageBox.critical(self.parentWidget(), "Błąd połączenia", f"Nie udało się pobrać listy wersji gry. Sprawdź połączenie z internetem.\n{e}")
2277 self.version_combo.addItem("Błąd pobierania listy wersji")
2278 self.version_combo.setEnabled(False)
2279 except Exception as e:
2280 logging.error(f"Nieoczekiwany błąd podczas pobierania listy wersji: {e}")
2281 QMessageBox.critical(self.parentWidget(), "Błąd", f"Nie udało się pobrać listy wersji gry: {e}")
2282 self.version_combo.addItem("Błąd ładowania listy wersji")
2283 self.version_combo.setEnabled(False)
2284 finally:
2285 self.version_combo.blockSignals(False)
2286
2287
2288 def update_modloaders(self):
2289 version_id = self.version_combo.currentText()
2290 self.modloader_combo.clear()
2291 self.modloader_combo.addItem("Brak")
2292 if not version_id or version_id.startswith("Błąd"):
2293 self.modloader_combo.setEnabled(False)
2294 return
2295 else:
2296 self.modloader_combo.setEnabled(True)
2297
2298 supported_modloaders = []
2299 for modloader in ["forge", "neoforge", "fabric", "quilt"]:
2300 if self.launcher.validate_modloader(modloader, version_id):
2301 supported_modloaders.append(modloader.capitalize())
2302
2303 if supported_modloaders:
2304 self.modloader_combo.addItems(supported_modloaders)
2305 elif re.match(r"^\d+w\d+[a-z]$", version_id):
2306 self.modloader_combo.addItem("Brak (Snapshot - brak oficjalnego wsparcia)")
2307
2308 def check_and_accept(self):
2309 name = self.name_input.text().strip()
2310 if not name:
2311 QMessageBox.warning(self, "Brak nazwy", "Proszę podać nazwę instancji.")
2312 return
2313
2314 version_id = self.version_combo.currentText()
2315 if not version_id or version_id.startswith("Błąd"):
2316 QMessageBox.warning(self, "Brak wersji", "Proszę wybrać poprawną wersję Minecrafta.")
2317 return
2318
2319 ram_val = self.ram_input.text().strip().upper()
2320 if not re.match(r"^\d+[MG]$", ram_val):
2321 QMessageBox.warning(self, "Nieprawidłowy format RAM", "Proszę podać RAM w formacie np. '4G' lub '2048M'.")
2322 return
2323
2324 selected_java_index = self.java_combo.currentIndex()
2325 if selected_java_index == -1:
2326 QMessageBox.warning(self, "Brak wyboru Javy", "Proszę wybrać wersję Javy lub 'Automatyczny wybór'.")
2327 return
2328 selected_java_path_data = self.java_combo.itemData(selected_java_index)
2329 selected_java_path = selected_java_path_data if selected_java_path_data is not None else "auto"
2330
2331 if selected_java_path != 'auto':
2332 if not Path(selected_java_path).exists():
2333 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'.")
2334 return
2335 required_java = self.launcher.get_required_java_version(version_id)
2336 selected_java_major = self.launcher.get_java_version_from_path(selected_java_path)
2337 if selected_java_major is not None and selected_java_major < required_java:
2338 reply = QMessageBox.question(self, "Niekompatybilna Java?",
2339 f"Wybrana wersja Javy ({selected_java_major}) może nie być kompatybilna z wersją Minecrafta {version_id} (wymaga {required_java}+). Czy chcesz kontynuować?",
2340 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
2341 if reply == QMessageBox.StandardButton.No:
2342 return
2343
2344 if self.use_custom_dir_check.isChecked():
2345 chosen_base_dir_str = self.instance_dir_input.text().strip()
2346 if not chosen_base_dir_str:
2347 QMessageBox.warning(self, "Brak folderu docelowego", "Proszę wybrać folder docelowy dla instancji.")
2348 return
2349
2350 self.accept()
2351
2352 def get_data(self):
2353 selected_java_index = self.java_combo.currentIndex()
2354 selected_java_path_data = self.java_combo.itemData(selected_java_index)
2355 java_path_setting_to_save = selected_java_path_data if selected_java_path_data is not None else "auto"
2356
2357 base_instance_dir_input_value = self.instance_dir_input.text().strip() if self.use_custom_dir_check.isChecked() else None
2358 if self.use_custom_dir_check.isChecked() and not base_instance_dir_input_value:
2359 base_instance_dir_input_value = None
2360
2361 return {
2362 "name": self.name_input.text().strip(),
2363 "version": self.version_combo.currentText(),
2364 "modloader": self.modloader_combo.currentText().lower() if self.modloader_combo.currentText() != "Brak" and "snapshot" not in self.modloader_combo.currentText().lower() else None,
2365 "ram": self.ram_input.text().strip().upper(),
2366 "java_path_setting": java_path_setting_to_save,
2367 "jvm_args_extra": self.jvm_args_input.text().strip(),
2368 "base_instance_dir_input": base_instance_dir_input_value,
2369 }
2370
2371class ModBrowserDialog(QDialog):
2372 def __init__(self, launcher, version_id, instance_dir, parent=None):
2373 super().__init__(parent)
2374 self.launcher = launcher
2375 self.version_id = version_id
2376 self.instance_dir = instance_dir
2377 self.setWindowTitle(f"Przeglądarka modów dla {version_id}")
2378 self.setMinimumSize(800, 600)
2379 self.current_mod = None
2380 self.selected_compatible_file = None
2381 self.init_ui()
2382 self.mod_list.clear()
2383 self.reset_details()
2384
2385 def init_ui(self):
2386 self.setStyleSheet(STYLESHEET)
2387 layout = QHBoxLayout(self)
2388 layout.setSpacing(10)
2389
2390 left_panel = QWidget()
2391 left_layout = QVBoxLayout(left_panel)
2392 left_layout.setSpacing(5)
2393
2394 self.search_input = QLineEdit()
2395 self.search_input.setPlaceholderText("Szukaj modów...")
2396 self.search_input.returnPressed.connect(self.search_mods)
2397 left_layout.addWidget(self.search_input)
2398
2399 self.mod_list = QListWidget()
2400 self.mod_list.setIconSize(QSize(48, 48))
2401 self.mod_list.itemClicked.connect(self.show_mod_details)
2402 self.mod_list.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
2403 left_layout.addWidget(self.mod_list)
2404
2405 layout.addWidget(left_panel, 1)
2406
2407 right_panel_scroll = QScrollArea()
2408 right_panel_scroll.setWidgetResizable(True)
2409 right_panel_scroll.setMinimumWidth(300)
2410 right_panel_widget = QWidget()
2411 self.details_layout = QVBoxLayout(right_panel_widget)
2412 self.details_layout.setSpacing(10)
2413 self.details_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
2414 right_panel_scroll.setWidget(right_panel_widget)
2415
2416 self.mod_icon = QLabel()
2417 self.mod_icon.setFixedSize(128, 128)
2418 self.mod_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
2419 self.mod_icon.setStyleSheet("border: 1px solid #ccc; background-color: #e0e0e0;")
2420 self.details_layout.addWidget(self.mod_icon)
2421
2422 self.mod_name = QLabel("Wybierz mod z listy")
2423 self.mod_name.setStyleSheet("font-size: 18px; font-weight: bold; margin-top: 5px;")
2424 self.mod_name.setWordWrap(True)
2425 self.details_layout.addWidget(self.mod_name)
2426
2427 self.mod_author = QLabel("Autor: Brak")
2428 self.details_layout.addWidget(self.mod_author)
2429
2430 self.mod_downloads = QLabel("Pobrania: Brak danych")
2431 self.details_layout.addWidget(self.mod_downloads)
2432
2433 self.mod_date = QLabel("Aktualizacja: Brak danych")
2434 self.details_layout.addWidget(self.mod_date)
2435
2436 self.mod_version = QLabel("Kompatybilna wersja pliku: Szukam...")
2437 self.details_layout.addWidget(self.mod_version)
2438
2439 self.mod_description_label = QLabel("Opis:")
2440 self.details_layout.addWidget(self.mod_description_label)
2441 self.mod_description = QTextEdit()
2442 self.mod_description.setReadOnly(True)
2443 self.mod_description.setMinimumHeight(150)
2444 self.mod_description.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
2445 self.details_layout.addWidget(self.mod_description)
2446
2447 self.dependency_check = QCheckBox("Pobierz wymagane mody (zalecane)")
2448 self.dependency_check.setChecked(True)
2449 self.details_layout.addWidget(self.dependency_check)
2450
2451 self.details_layout.addStretch(1)
2452
2453 button_layout = QHBoxLayout()
2454 self.install_button = QPushButton("Zainstaluj")
2455 self.install_button.clicked.connect(self.install_mod)
2456 self.install_button.setEnabled(False)
2457 self.remove_button = QPushButton("Usuń")
2458 self.remove_button.setProperty("deleteButton", "true")
2459 self.remove_button.clicked.connect(self.remove_mod)
2460 self.remove_button.setEnabled(False)
2461 self.remove_button.setStyleSheet("background-color: #f44336;")
2462 self.remove_button.setStyleSheet(self.remove_button.styleSheet() + """
2463 QPushButton:hover { background-color: #d32f2f; }
2464 QPushButton:disabled { background-color: #cccccc; }
2465 """)
2466
2467 button_layout.addWidget(self.install_button)
2468 button_layout.addWidget(self.remove_button)
2469 self.details_layout.addLayout(button_layout)
2470
2471 close_button = QPushButton("Zamknij")
2472 close_button.clicked.connect(self.accept)
2473 self.details_layout.addWidget(close_button)
2474
2475 layout.addWidget(right_panel_scroll, 2)
2476
2477 def search_mods(self):
2478 query = self.search_input.text().strip()
2479 if not query:
2480 self.mod_list.clear()
2481 self.reset_details()
2482 self.mod_name.setText("Wprowadź frazę do wyszukiwania.")
2483 return
2484
2485 logging.info(f"Wyszukiwanie modów: '{query}' dla wersji {self.version_id}")
2486 self.mod_list.clear()
2487 self.reset_details()
2488 self.mod_name.setText("Szukam modów...")
2489 self.setCursor(Qt.CursorShape.WaitCursor)
2490
2491 try:
2492 mods = self.launcher.get_curseforge_mods(query, self.version_id)
2493 self.unsetCursor()
2494 if not mods:
2495 self.mod_name.setText("Brak wyników.")
2496 return
2497
2498 self.mod_name.setText("Wybierz mod z listy")
2499
2500 for mod in mods:
2501 compatible_file = None
2502 files = mod.get("latestFiles", [])
2503 files.sort(key=lambda x: x.get('fileDate', '1970-01-01T00:00:00Z'), reverse=True)
2504 for file in files:
2505 if self.version_id in file.get("gameVersions", []):
2506 compatible_file = file
2507 break
2508
2509 item_text = f"{mod.get('name', 'Nazwa nieznana')}"
2510 if not compatible_file:
2511 item_text += " (Brak wersji dla tej gry)"
2512
2513 list_item = QListWidgetItem(item_text)
2514
2515 item_data = {
2516 'mod': mod,
2517 'compatible_file': compatible_file
2518 }
2519 list_item.setData(Qt.ItemDataRole.UserRole, item_data)
2520
2521 icon_url = mod.get("logo", {}).get("url")
2522 if icon_url:
2523 icon_file_extension = Path(icon_url).suffix or ".png"
2524 icon_dest_path = MOD_ICONS_DIR / f"{mod['id']}{icon_file_extension}"
2525
2526 if icon_dest_path.exists():
2527 list_item.setIcon(QIcon(str(icon_dest_path)))
2528 else:
2529 pass
2530
2531 self.mod_list.addItem(list_item)
2532
2533 except (requests.exceptions.RequestException, PermissionError) as e:
2534 self.unsetCursor()
2535 QMessageBox.critical(self, "Błąd API CurseForge", f"Wystąpił błąd podczas wyszukiwania modów:\n{e}")
2536 logging.error(f"Błąd wyszukiwania modów: {e}")
2537 self.mod_name.setText("Błąd API CurseForge.")
2538 except Exception as e:
2539 self.unsetCursor()
2540 logging.error(f"Nieoczekiwany błąd podczas wyszukiwania modów: {e}")
2541 QMessageBox.critical(self, "Błąd wyszukiwania modów", f"Nie udało się wyszukać modów: {e}")
2542 self.mod_name.setText("Błąd wyszukiwania.")
2543
2544
2545 def show_mod_details(self, item):
2546 item_data = item.data(Qt.ItemDataRole.UserRole)
2547 mod = item_data.get('mod')
2548 compatible_file = item_data.get('compatible_file')
2549
2550 if not mod:
2551 self.reset_details()
2552 return
2553
2554 self.current_mod = mod
2555 self.selected_compatible_file = compatible_file
2556
2557 self.mod_name.setText(mod.get("name", "Nazwa nieznana"))
2558 authors = mod.get("authors", [])
2559 self.mod_author.setText(f"Autor: {authors[0].get('name', 'Brak danych') if authors else 'Brak danych'}")
2560 self.mod_downloads.setText(f"Pobrania: {mod.get('downloadCount', 'Brak danych')}")
2561 try:
2562 date_modified_ts = mod.get('dateModified')
2563 if date_modified_ts is not None:
2564 date_modified = datetime.fromtimestamp(date_modified_ts / 1000).strftime('%Y-%m-%d %H:%M')
2565 self.mod_date.setText(f"Aktualizacja: {date_modified}")
2566 else:
2567 self.mod_date.setText("Aktualizacja: Brak danych")
2568 except Exception as e:
2569 logging.warning(f"Błąd parsowania daty modyfikacji dla mod ID {mod.get('id')}: {e}")
2570 self.mod_date.setText("Aktualizacja: Nieprawidłowa data")
2571
2572 if compatible_file:
2573 self.mod_version.setText(f"Kompatybilny plik: {compatible_file.get('fileName', 'Brak danych')}")
2574 self.install_button.setEnabled(True)
2575 mod_file_name = compatible_file.get("fileName")
2576 if mod_file_name:
2577 mod_path = Path(self.instance_dir) / "mods" / mod_file_name
2578 self.remove_button.setEnabled(mod_path.exists())
2579 else:
2580 self.remove_button.setEnabled(False)
2581
2582 else:
2583 self.mod_version.setText("Kompatybilny plik: Brak dla tej wersji")
2584 self.install_button.setEnabled(False)
2585 self.remove_button.setEnabled(False)
2586
2587 description_text = mod.get("summary", "")
2588 self.mod_description.setHtml(description_text or "Brak opisu.")
2589
2590 self.mod_icon.clear()
2591 icon_url = mod.get("logo", {}).get("url")
2592 if icon_url:
2593 icon_file_extension = Path(icon_url).suffix or ".png"
2594 icon_dest_path = MOD_ICONS_DIR / f"{mod['id']}{icon_file_extension}"
2595
2596 if icon_dest_path.exists():
2597 try:
2598 pixmap = QPixmap(str(icon_dest_path)).scaled(128, 128, Qt.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
2599 self.mod_icon.setPixmap(pixmap)
2600 except Exception as e:
2601 logging.warning(f"Błąd ładowania ikony z pliku {icon_dest_path}: {e}")
2602 self.mod_icon.setText("Błąd ikony")
2603 else:
2604 self.mod_icon.setText("Ładowanie ikony...")
2605
2606 else:
2607 self.mod_icon.setText("Brak ikony")
2608
2609 def reset_details(self):
2610 self.current_mod = None
2611 self.selected_compatible_file = None
2612 self.mod_icon.clear()
2613 self.mod_icon.setText("Ikona")
2614 self.mod_name.setText("Wybierz mod z listy")
2615 self.mod_author.setText("Autor: Brak")
2616 self.mod_downloads.setText("Pobrania: Brak danych")
2617 self.mod_date.setText("Aktualizacja: Brak danych")
2618 self.mod_version.setText("Kompatybilny plik: Brak danych")
2619 self.mod_description.setHtml("Wybierz mod z listy, aby zobaczyć szczegóły.")
2620 self.install_button.setEnabled(False)
2621 self.remove_button.setEnabled(False)
2622 self.dependency_check.setChecked(True)
2623
2624 def install_mod(self):
2625 if not self.current_mod or not self.selected_compatible_file:
2626 QMessageBox.warning(self, "Błąd", "Proszę wybrać mod do instalacji i upewnić się, że jest dostępna kompatybilna wersja pliku.")
2627 return
2628
2629 mod_id = self.current_mod.get("id")
2630 mod_name_display = self.current_mod.get("name", "Wybrany mod")
2631 download_deps = self.dependency_check.isChecked()
2632
2633 reply = QMessageBox.question(self, "Potwierdzenie instalacji",
2634 f"Zainstalować mod '{mod_name_display}' (dla wersji {self.version_id})?",
2635 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
2636 if reply == QMessageBox.StandardButton.No:
2637 return
2638
2639 if self.launcher.current_download_thread or self.launcher.download_queue:
2640 QMessageBox.warning(self, "Pobieranie aktywne", "Inny proces pobierania jest aktywny. Poczekaj na jego zakończenie lub anuluj.")
2641 return
2642
2643 try:
2644 logging.info(f"Rozpoczęcie instalacji moda '{mod_name_display}' (ID: {mod_id}) dla wersji {self.version_id}")
2645 self.launcher.download_queue.clear()
2646
2647 visited_mods_during_install = set()
2648 total_queued = self.launcher._queue_curseforge_mod_files(
2649 mod_id, self.version_id, self.instance_dir,
2650 download_dependencies=download_deps,
2651 visited_mods=visited_mods_during_install
2652 )
2653
2654 if total_queued == 0:
2655 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.")
2656 logging.warning("Install mod: No files queued.")
2657 self.show_mod_details(self.mod_list.currentItem())
2658 return
2659
2660 logging.info(f"Kolejka pobierania modów gotowa. Plików do pobrania: {total_queued}")
2661 self.launcher.progress_dialog = DownloadProgressDialog(self.launcher, self) # Pass launcher to dialog
2662 self.launcher.progress_dialog.set_total_files(total_queued)
2663 self.launcher.progress_dialog.cancel_signal.connect(self.launcher.cancel_downloads)
2664 self.launcher.progress_dialog.download_process_finished.connect(self._handle_mod_install_post_download)
2665
2666 self._post_mod_install_data = {
2667 "mod_name": mod_name_display,
2668 "mod_id": mod_id,
2669 "parent_dialog": self
2670 }
2671
2672 self.launcher.process_download_queue()
2673 self.launcher.progress_dialog.exec()
2674
2675 except (ValueError, requests.exceptions.RequestException, PermissionError, Exception) as e:
2676 logging.error(f"Błąd podczas przygotowania instalacji moda '{mod_name_display}': {e}")
2677 QMessageBox.critical(self, "Błąd instalacji moda", f"Nie udało się przygotować instalacji moda:\n{e}")
2678 self.launcher.download_queue.clear()
2679
2680 def _handle_mod_install_post_download(self, success):
2681 if self.launcher.progress_dialog:
2682 post_data = self._post_mod_install_data
2683 QTimer.singleShot(0, self.launcher.progress_dialog.deleteLater)
2684 self.launcher.progress_dialog = None
2685
2686 if post_data is None:
2687 logging.error("Brak danych do konfiguracji po pobraniu moda. Nie mogę zakończyć instalacji.")
2688 QMessageBox.critical(self, "Błąd instalacji moda", "Wystąpił wewnętrzny błąd po pobraniu moda. Spróbuj ponownie.")
2689 return
2690
2691 mod_name = post_data.get("mod_name", "Mod")
2692 mod_id = post_data.get("mod_id")
2693 parent_dialog = post_data.get("parent_dialog")
2694
2695 self._post_mod_install_data = None
2696
2697 if success:
2698 logging.info(f"Mod '{mod_name}' zainstalowany pomyślnie.")
2699 QMessageBox.information(parent_dialog, "Sukces", f"Mod '{mod_name}' zainstalowany pomyślnie!")
2700 if mod_id is not None:
2701 for i in range(self.mod_list.count()):
2702 item = self.mod_list.item(i)
2703 item_data = item.data(Qt.ItemDataRole.UserRole)
2704 if item_data and item_data.get('mod', {}).get('id') == mod_id:
2705 self.mod_list.setCurrentItem(item)
2706 self.show_mod_details(item)
2707 break
2708 else:
2709 logging.warning(f"Instalacja moda '{mod_name}' anulowana lub zakończona z błędami.")
2710 QMessageBox.warning(parent_dialog, "Instalacja anulowana", f"Instalacja moda '{mod_name}' została anulowana lub napotkała błędy.")
2711
2712 def remove_mod(self):
2713 if not self.current_mod or not self.selected_compatible_file:
2714 QMessageBox.warning(self, "Błąd", "Proszę wybrać mod do usunięcia.")
2715 return
2716
2717 mod_name_display = self.current_mod.get("name", "Wybrany mod")
2718 mod_file_name = self.selected_compatible_file.get("fileName")
2719
2720 if not mod_file_name:
2721 QMessageBox.warning(self, "Błąd", "Nie można określić nazwy pliku moda do usunięcia.")
2722 return
2723
2724 mod_path = Path(self.instance_dir) / "mods" / mod_file_name
2725 if not mod_path.exists():
2726 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.")
2727 self.remove_button.setEnabled(False)
2728 return
2729
2730 reply = QMessageBox.question(self, "Potwierdzenie usunięcia",
2731 f"Usunąć mod '{mod_name_display}' ({mod_file_name})?",
2732 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
2733 if reply == QMessageBox.StandardButton.No:
2734 return
2735
2736 try:
2737 self.launcher.remove_mod(mod_file_name, self.instance_dir)
2738 QMessageBox.information(self, "Sukces", f"Usunięto mod: {mod_file_name}")
2739 self.remove_button.setEnabled(False)
2740 except FileNotFoundError:
2741 QMessageBox.warning(self, "Błąd usuwania", f"Plik moda nie znaleziono w katalogu: {mod_file_name}")
2742 self.remove_button.setEnabled(False)
2743 except IOError as e:
2744 QMessageBox.critical(self, "Błąd usuwania", f"Wystąpił błąd podczas usuwania pliku:\n{e}")
2745 except Exception as e:
2746 logging.error(f"Nieoczekiwany błąd podczas usuwania moda {mod_file_name}: {e}")
2747 QMessageBox.critical(self, "Błąd usuwania", f"Wystąpił nieoczekiwany błąd podczas usuwania moda:\n{e}")
2748
2749class EditInstanceDialog(QDialog):
2750 def __init__(self, instance_dir, parent=None):
2751 super().__init__(parent)
2752 self.instance_dir = Path(instance_dir)
2753 self.setWindowTitle("Edytuj instancję")
2754 self.setFixedSize(400, 300)
2755 self.init_ui()
2756 self.load_settings()
2757
2758 def init_ui(self):
2759 layout = QVBoxLayout()
2760
2761 # RAM
2762 ram_label = QLabel("Maksymalna pamięć RAM:")
2763 self.ram_input = QComboBox()
2764 self.ram_input.addItems(["2G", "4G", "6G", "8G", "12G", "16G"])
2765 layout.addWidget(ram_label)
2766 layout.addWidget(self.ram_input)
2767
2768 # Java path
2769 java_label = QLabel("Ścieżka do Javy (puste = automatyczne):")
2770 self.java_input = QLineEdit()
2771 java_browse = QPushButton("Przeglądaj")
2772 java_browse.clicked.connect(self.browse_java)
2773 java_layout = QHBoxLayout()
2774 java_layout.addWidget(self.java_input)
2775 java_layout.addWidget(java_browse)
2776 layout.addWidget(java_label)
2777 layout.addLayout(java_layout)
2778
2779 # Rozdzielczość
2780 resolution_label = QLabel("Rozdzielczość (np. 1280x720):")
2781 self.resolution_input = QLineEdit()
2782 layout.addWidget(resolution_label)
2783 layout.addWidget(self.resolution_input)
2784
2785 # Pełny ekran
2786 self.fullscreen_checkbox = QCheckBox("Pełny ekran")
2787 layout.addWidget(self.fullscreen_checkbox)
2788
2789 # Przyciski
2790 buttons = QHBoxLayout()
2791 save_button = QPushButton("Zapisz")
2792 save_button.clicked.connect(self.save_settings)
2793 cancel_button = QPushButton("Anuluj")
2794 cancel_button.clicked.connect(self.reject)
2795 buttons.addWidget(save_button)
2796 buttons.addWidget(cancel_button)
2797 layout.addLayout(buttons)
2798
2799 self.setLayout(layout)
2800
2801 def browse_java(self):
2802 java_path, _ = QFileDialog.getOpenFileName(self, "Wybierz plik java.exe", "", "Pliki wykonywalne (*.exe);;Wszystkie pliki (*.*)")
2803 if java_path:
2804 self.java_input.setText(java_path)
2805
2806 def load_settings(self):
2807 settings_path = self.instance_dir / "settings.json"
2808 if settings_path.exists():
2809 try:
2810 with settings_path.open("r", encoding='utf-8') as f:
2811 settings = json.load(f)
2812 self.ram_input.setCurrentText(settings.get("ram", "4G"))
2813 self.java_input.setText(settings.get("java_path", ""))
2814 self.resolution_input.setText(settings.get("resolution", "1280x720"))
2815 self.fullscreen_checkbox.setChecked(settings.get("fullscreen", False))
2816 except Exception as e:
2817 logging.error(f"Błąd ładowania ustawień instancji {settings_path}: {e}")
2818
2819 def save_settings(self):
2820 settings = {
2821 "version": self.load_settings_version(),
2822 "ram": self.ram_input.currentText(),
2823 "java_path": self.java_input.text(),
2824 "resolution": self.resolution_input.text(),
2825 "fullscreen": self.fullscreen_checkbox.isChecked(),
2826 }
2827 settings_path = self.instance_dir / "settings.json"
2828 try:
2829 with settings_path.open("w", encoding='utf-8') as f:
2830 json.dump(settings, f, indent=4)
2831 logging.info(f"Zapisano ustawienia instancji: {settings_path}")
2832 self.accept()
2833 except Exception as e:
2834 logging.error(f"Błąd zapisu ustawień instancji {settings_path}: {e}")
2835 QMessageBox.critical(self, "Błąd", f"Nie udało się zapisać ustawień: {e}")
2836
2837 def load_settings_version(self):
2838 settings_path = self.instance_dir / "settings.json"
2839 if settings_path.exists():
2840 try:
2841 with settings_path.open("r", encoding='utf-8') as f:
2842 settings = json.load(f)
2843 return settings.get("version", "")
2844 except:
2845 return ""
2846 return ""
2847
2848class LauncherWindow(QMainWindow):
2849 def __init__(self):
2850 super().__init__()
2851 self.launcher = MinecraftLauncher()
2852 self.setWindowTitle("Paffcio's Minecraft Launcher")
2853 self.setGeometry(100, 100, 900, 650)
2854 self.selected_instance_dir = None
2855 self.init_ui()
2856 self.apply_theme()
2857 self.update_instance_tiles()
2858
2859 def update_buttons_state(self):
2860 """
2861 Aktualizuje stan przycisków w zależności od wybranej instancji.
2862 """
2863 has_selection = bool(self.instance_list.selectedItems())
2864 has_valid_settings = False
2865 version_id = None
2866
2867 if has_selection:
2868 current_item = self.instance_list.currentItem()
2869 instance_dir_path = current_item.data(Qt.ItemDataRole.UserRole)
2870 settings_path = Path(instance_dir_path) / "settings.json"
2871 if settings_path.exists():
2872 try:
2873 with settings_path.open("r", encoding='utf-8') as f:
2874 settings = json.load(f)
2875 version_id = settings.get("version")
2876 has_valid_settings = bool(version_id)
2877 except Exception as e:
2878 logging.error(f"Błąd odczytu settings.json dla instancji {instance_dir_path}: {e}")
2879
2880 self.play_button.setEnabled(has_selection and has_valid_settings)
2881 self.edit_instance_button.setEnabled(has_selection)
2882 self.mod_browser_button.setEnabled(has_selection and has_valid_settings)
2883 self.delete_instance_button.setEnabled(has_selection)
2884 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()}")
2885
2886 def init_ui(self):
2887 # Główny widget i layout
2888 main_widget = QWidget()
2889 self.setCentralWidget(main_widget)
2890 main_layout = QVBoxLayout(main_widget)
2891 main_layout.setContentsMargins(10, 10, 10, 10)
2892 main_layout.setSpacing(10)
2893 logging.debug("Inicjalizacja głównego layoutu")
2894
2895 # Layout na sidebar i główną zawartość
2896 content_layout = QHBoxLayout()
2897 content_layout.setSpacing(10)
2898
2899 # Sidebar
2900 sidebar = QWidget()
2901 sidebar.setMinimumWidth(200)
2902 sidebar.setMaximumWidth(300)
2903 sidebar_layout = QVBoxLayout(sidebar)
2904 sidebar_layout.setContentsMargins(0, 0, 0, 0)
2905 sidebar_layout.setSpacing(5)
2906 logging.debug("Inicjalizacja sidebara")
2907
2908 sidebar_layout.addWidget(QLabel("Twoje instancje:"))
2909 self.instance_list = QListWidget()
2910 self.instance_list.itemSelectionChanged.connect(self.handle_instance_selection_change)
2911 self.instance_list.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
2912 sidebar_layout.addWidget(self.instance_list)
2913 logging.debug("Dodano listę instancji")
2914
2915 # Przyciski akcji instancji
2916 instance_actions_layout = QVBoxLayout()
2917 instance_actions_layout.setSpacing(5)
2918
2919 self.play_button = QPushButton("Graj")
2920 self.play_button.clicked.connect(self.play_instance)
2921 self.play_button.setEnabled(False)
2922 instance_actions_layout.addWidget(self.play_button)
2923 logging.debug("Dodano przycisk Graj")
2924
2925 self.edit_instance_button = QPushButton("Edytuj instancję")
2926 self.edit_instance_button.clicked.connect(self.edit_instance)
2927 self.edit_instance_button.setEnabled(False)
2928 self.edit_instance_button.setStyleSheet("background-color: #2196F3; color: white;") # Tymczasowy styl dla widoczności
2929 instance_actions_layout.addWidget(self.edit_instance_button)
2930 logging.debug("Dodano przycisk Edytuj instancję")
2931
2932 self.mod_browser_button = QPushButton("Przeglądaj mody")
2933 self.mod_browser_button.clicked.connect(self.open_mod_browser)
2934 self.mod_browser_button.setEnabled(False)
2935 instance_actions_layout.addWidget(self.mod_browser_button)
2936 logging.debug("Dodano przycisk Przeglądaj mody")
2937
2938 self.delete_instance_button = QPushButton("Usuń instancję")
2939 self.delete_instance_button.setProperty("deleteButton", "true")
2940 self.delete_instance_button.clicked.connect(self.delete_instance)
2941 self.delete_instance_button.setEnabled(False)
2942 instance_actions_layout.addWidget(self.delete_instance_button)
2943 logging.debug("Dodano przycisk Usuń instancję")
2944
2945 sidebar_layout.addLayout(instance_actions_layout)
2946 content_layout.addWidget(sidebar, 1)
2947 logging.debug("Dodano sidebar do content_layout")
2948
2949 # Główna zawartość
2950 main_content_area = QWidget()
2951 main_content_layout = QVBoxLayout(main_content_area)
2952 main_content_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
2953 main_content_layout.addStretch(1)
2954 self.main_info_label = QLabel("Wybierz instancję z listy po lewej lub stwórz nową instancję (Plik -> Nowa instancja...).")
2955 self.main_info_label.setWordWrap(True)
2956 self.main_info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
2957 main_content_layout.addWidget(self.main_info_label)
2958 main_content_layout.addStretch(2)
2959 content_layout.addWidget(main_content_area, 3)
2960 main_layout.addLayout(content_layout)
2961 logging.debug("Dodano główną zawartość")
2962
2963 # Status bar
2964 self.status_label = QLabel("Gotowy.")
2965 self.status_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
2966 main_layout.addWidget(self.status_label)
2967 logging.debug("Dodano status bar")
2968
2969 # Menu
2970 menubar = self.menuBar()
2971 file_menu = menubar.addMenu("Plik")
2972 file_menu.addAction("Nowa instancja...", self.create_instance)
2973 file_menu.addSeparator()
2974 file_menu.addAction("Importuj instancję...", self.import_instance)
2975 file_menu.addAction("Eksportuj wybraną instancję...", self.export_instance)
2976 file_menu.addSeparator()
2977 file_menu.addAction("Zamknij", self.close)
2978
2979 settings_menu = menubar.addMenu("Ustawienia")
2980 settings_menu.addAction("Ustawienia launchera...", self.open_settings)
2981
2982 accounts_menu = menubar.addMenu("Konta")
2983 accounts_menu.addAction("Ustaw nazwę konta offline...", self.set_offline_account)
2984 logging.debug("Dodano menu")
2985
2986 def edit_instance(self):
2987 """
2988 Otwiera okno edycji wybranej instancji.
2989 """
2990 selected_items = self.instance_list.selectedItems()
2991 if not selected_items:
2992 logging.warning("Próba edycji instancji bez wybrania instancji.")
2993 return
2994 instance_name = selected_items[0].text()
2995 instance_dir = Path(self.launcher.instances_dir) / instance_name
2996 dialog = EditInstanceDialog(instance_dir, self)
2997 dialog.exec_()
2998 logging.info(f"Otwarto okno edycji dla instancji: {instance_name}")
2999
3000 def apply_theme(self):
3001 theme = self.launcher.settings.get("theme", "Light")
3002 if theme == "Light":
3003 self.setStyleSheet(STYLESHEET)
3004 else:
3005 dark_stylesheet = STYLESHEET + """
3006 QMainWindow, QDialog, QWidget {
3007 background-color: #2e2e2e;
3008 color: #cccccc;
3009 }
3010 QLabel {
3011 color: #cccccc;
3012 }
3013 QListWidget {
3014 background-color: #3a3a3a;
3015 color: #cccccc;
3016 border: 1px solid #555555;
3017 }
3018 QListWidget::item:selected {
3019 background-color: #5a5a5a;
3020 color: #ffffff;
3021 }
3022 QLineEdit, QComboBox, QTextEdit {
3023 background-color: #4a4a4a;
3024 color: #cccccc;
3025 border: 1px solid #666666;
3026 }
3027 QTextEdit {
3028 background-color: #3a3a3a;
3029 border: 1px solid #555555;
3030 }
3031 QPushButton {
3032 background-color: #4CAF50;
3033 color: white;
3034 }
3035 QPushButton:hover {
3036 background-color: #45a049;
3037 }
3038 QPushButton:disabled {
3039 background-color: #555555;
3040 color: #aaaaaa;
3041 }
3042 QPushButton[deleteButton="true"] {
3043 background-color: #c62828;
3044 }
3045 QPushButton[deleteButton="true"]:hover {
3046 background-color: #d32f2f;
3047 }
3048 QPushButton[deleteButton="true"]:disabled {
3049 background-color: #555555;
3050 }
3051 QProgressBar {
3052 background-color: #555555;
3053 border: 1px solid #666666;
3054 }
3055 QProgressBar::chunk {
3056 background-color: #4CAF50;
3057 }
3058 QScrollArea {
3059 border: none;
3060 }
3061 """
3062 self.setStyleSheet(dark_stylesheet)
3063
3064 # Ustaw atrybut deleteButton dla przycisków usuwania
3065 self.delete_instance_button.setProperty("deleteButton", "true")
3066 self.delete_instance_button.style().unpolish(self.delete_instance_button)
3067 self.delete_instance_button.style().polish(self.delete_instance_button)
3068
3069 def update_instance_tiles(self):
3070 """
3071 Odświeża listę instancji w UI.
3072 """
3073 logging.info("Odświeżanie listy instancji...")
3074 current_selection_path = self.selected_instance_dir
3075 self.instance_list.clear()
3076 self.selected_instance_dir = None
3077
3078 instances = self.launcher.get_instance_list()
3079
3080 if not instances:
3081 self.status_label.setText("Brak instancji. Stwórz nową (Plik -> Nowa instancja...).")
3082 self.main_info_label.setText("Brak instancji. Stwórz nową instancję (Plik -> Nowa instancja...).")
3083 self.update_buttons_state()
3084 return
3085
3086 found_selected_index = -1
3087 for i, (name, path) in enumerate(instances):
3088 item = QListWidgetItem(name)
3089 item.setData(Qt.ItemDataRole.UserRole, path)
3090 self.instance_list.addItem(item)
3091 if path == current_selection_path:
3092 found_selected_index = i
3093
3094 logging.info(f"Znaleziono {len(instances)} instancji.")
3095 self.status_label.setText(f"Znaleziono {len(instances)} instancji.")
3096 self.main_info_label.setText("Wybierz instancję z listy po lewej lub stwórz nową instancję (Plik -> Nowa instancja...).")
3097
3098 if found_selected_index != -1:
3099 self.instance_list.setCurrentRow(found_selected_index)
3100 else:
3101 if self.instance_list.count() > 0:
3102 self.instance_list.setCurrentRow(0)
3103 else:
3104 self.update_buttons_state()
3105
3106 def handle_instance_selection_change(self):
3107 """
3108 Obsługuje zmianę wybranej instancji w liście.
3109 """
3110 current_item = self.instance_list.currentItem()
3111 if current_item:
3112 self.load_instance(current_item)
3113 else:
3114 self.selected_instance_dir = None
3115 self.update_buttons_state()
3116 self.status_label.setText("Gotowy.")
3117
3118 def create_instance(self):
3119 if self.launcher.current_download_thread or self.launcher.download_queue:
3120 QMessageBox.warning(self, "Pobieranie aktywne", "Inny proces pobierania jest aktywny. Poczekaj na jego zakończenie lub anuluj.")
3121 return
3122
3123 dialog = CreateInstanceDialog(self.launcher, self)
3124 if dialog.exec():
3125 data = dialog.get_data()
3126 try:
3127 self.launcher.create_instance(
3128 name=data["name"],
3129 version_id=data["version"],
3130 modloader=data["modloader"],
3131 ram=data["ram"],
3132 java_path_setting=data["java_path_setting"],
3133 jvm_args_extra=data["jvm_args_extra"],
3134 base_instance_dir_input=data["base_instance_dir_input"],
3135 parent_window=self
3136 )
3137
3138 except (ValueError, FileExistsError, FileNotFoundError, ConnectionError, PermissionError, Exception) as e:
3139 error_title = "Błąd tworzenia instancji"
3140 if isinstance(e, FileExistsError):
3141 error_title = "Katalog instancji już istnieje"
3142 elif isinstance(e, FileNotFoundError):
3143 error_title = "Wymagany plik/folder nie znaleziono"
3144 elif isinstance(e, ConnectionError):
3145 error_title = "Błąd połączenia sieciowego"
3146 elif isinstance(e, PermissionError):
3147 error_title = "Błąd uprawnień (klucz API?)"
3148
3149 QMessageBox.critical(self, error_title, f"Nie udało się przygotować instancji:\n{e}")
3150 self.update_instance_tiles()
3151
3152 def import_instance(self):
3153 file, _ = QFileDialog.getOpenFileName(self, "Importuj instancję", "", "Archiwa ZIP (*.zip);;Wszystkie pliki (*)")
3154 if file:
3155 if self.launcher.current_download_thread or self.launcher.download_queue:
3156 QMessageBox.warning(self, "Pobieranie aktywne", "Inny proces pobierania jest aktywny. Poczekaj na jego zakończenie lub anuluj.")
3157 return
3158
3159 try:
3160 imported_dir = self.launcher.import_instance(file)
3161 QMessageBox.information(self, "Sukces", f"Instancja zaimportowana pomyślnie do:\n{imported_dir}")
3162 self.update_instance_tiles()
3163 except (FileNotFoundError, zipfile.BadZipFile, ValueError, Exception) as e:
3164 error_title = "Błąd importu instancji"
3165 if isinstance(e, FileNotFoundError):
3166 error_title = "Plik nie znaleziono"
3167 elif isinstance(e, zipfile.BadZipFile):
3168 error_title = "Nieprawidłowy plik ZIP"
3169 QMessageBox.critical(self, error_title, f"Nie udało się zaimportować instancji:\n{e}")
3170
3171 def export_instance(self):
3172 current_item = self.instance_list.currentItem()
3173 if not current_item or not self.selected_instance_dir:
3174 QMessageBox.warning(self, "Brak wybranej instancji", "Proszę wybrać instancję do eksportu.")
3175 return
3176
3177 instance_name = current_item.text()
3178 instance_dir_path = self.selected_instance_dir
3179
3180 if not Path(instance_dir_path).exists():
3181 self.update_instance_tiles()
3182 return
3183
3184 default_filename = f"{instance_name}.zip"
3185 start_dir = str(Path(instance_dir_path).parent)
3186 if not Path(start_dir).exists():
3187 start_dir = str(Path.home())
3188
3189 file, _ = QFileDialog.getSaveFileName(self, f"Eksportuj instancję '{instance_name}'", os.path.join(start_dir, default_filename), "Archiwa ZIP (*.zip);;Wszystkie pliki (*)")
3190 if file:
3191 if not file.lower().endswith('.zip'):
3192 file += '.zip'
3193 try:
3194 self.launcher.export_instance(instance_dir_path, file)
3195 QMessageBox.information(self, "Sukces", f"Instancja '{instance_name}' wyeksportowana pomyślnie do:\n{file}")
3196 except (FileNotFoundError, IOError, Exception) as e:
3197 error_title = "Błąd eksportu instancji"
3198 if isinstance(e, FileNotFoundError):
3199 error_title = "Katalog instancji nie znaleziono"
3200 elif isinstance(e, IOError):
3201 error_title = "Błąd zapisu pliku"
3202
3203 QMessageBox.critical(self, error_title, f"Nie udało się wyeksportować instancji '{instance_name}':\n{e}")
3204
3205 def delete_instance(self):
3206 current_item = self.instance_list.currentItem()
3207 if not current_item or not self.selected_instance_dir:
3208 QMessageBox.warning(self, "Brak wybranej instancji", "Proszę wybrać instancję do usunięcia.")
3209 self.delete_instance_button.setEnabled(False)
3210 return
3211
3212 instance_name = current_item.text()
3213 instance_dir_path = self.selected_instance_dir
3214 instance_dir = Path(instance_dir_path)
3215
3216 if not instance_dir.exists():
3217 QMessageBox.warning(self, "Błąd", "Katalog instancji już nie istnieje. Odświeżam listę.")
3218 self.update_instance_tiles()
3219 return
3220
3221 reply = QMessageBox.question(self, "Potwierdzenie usunięcia",
3222 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}",
3223 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
3224 QMessageBox.StandardButton.No)
3225
3226 if reply == QMessageBox.StandardButton.Yes:
3227 try:
3228 logging.info(f"Usuwanie instancji: {instance_dir_path}")
3229 shutil.rmtree(instance_dir)
3230 logging.info("Instancja usunięta pomyślnie.")
3231 QMessageBox.information(self, "Sukces", f"Instancja '{instance_name}' została usunięta.")
3232 self.update_instance_tiles()
3233 except Exception as e:
3234 logging.error(f"Błąd podczas usuwania instancji {instance_dir_path}: {e}")
3235 QMessageBox.critical(self, "Błąd usuwania instancji", f"Nie udało się usunąć instancji '{instance_name}':\n{e}")
3236
3237
3238 def load_instance(self, item):
3239 instance_name = item.text()
3240 instance_dir_path = item.data(Qt.ItemDataRole.UserRole)
3241 logging.info(f"Wybrano instancję: '{instance_name}' w katalogu {instance_dir_path}")
3242
3243 instance_dir = Path(instance_dir_path)
3244 if not instance_dir.exists():
3245 logging.error(f"Katalog instancji nie istnieje: {instance_dir_path}")
3246 self.instance_list.takeItem(self.instance_list.row(item))
3247 self.handle_instance_selection_change()
3248 return
3249
3250 self.selected_instance_dir = instance_dir_path
3251
3252 settings_path = instance_dir / "settings.json"
3253 has_settings = False
3254 version_id = None
3255
3256 if settings_path.exists():
3257 try:
3258 with settings_path.open("r", encoding='utf-8') as f:
3259 settings = json.load(f)
3260 version_id = settings.get("version")
3261 has_settings = True
3262 except json.JSONDecodeError as e:
3263 logging.error(f"Błąd odczytu settings.json dla instancji {instance_name}: {e}")
3264 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.")
3265 except Exception as e:
3266 logging.error(f"Nieoczekiwany błąd podczas ładowania settings.json dla instancji {instance_name}: {e}")
3267 QMessageBox.critical(self, "Błąd ładowania instancji", f"Wystąpił nieoczekiwany błąd podczas odczytu ustawień instancji '{instance_name}'.")
3268
3269 self.play_button.setEnabled(has_settings and version_id is not None)
3270 self.mod_browser_button.setEnabled(has_settings and version_id is not None)
3271 self.delete_instance_button.setEnabled(True)
3272
3273 def play_instance(self):
3274 current_item = self.instance_list.currentItem()
3275 instance_dir_path = self.selected_instance_dir
3276
3277 if not current_item or not instance_dir_path:
3278 QMessageBox.warning(self, "Brak wybranej instancji", "Proszę wybrać instancję do uruchomienia.")
3279 self.play_button.setEnabled(False)
3280 return
3281
3282 instance_name = current_item.text()
3283
3284 if not Path(instance_dir_path).exists():
3285 QMessageBox.critical(self, "Błąd uruchamiania", "Katalog wybranej instancji nie istnieje! Odświeżam listę.")
3286 self.update_instance_tiles()
3287 return
3288
3289 if self.launcher.current_download_thread or self.launcher.download_queue:
3290 QMessageBox.warning(self, "Pobieranie aktywne", "Inny proces pobierania jest aktywny. Proszę poczekać na jego zakończenie przed uruchomieniem gry.")
3291 return
3292
3293 username = self.launcher.settings.get("default_account", DEFAULT_SETTINGS["default_account"])
3294 if not username or not username.strip():
3295 username, ok = QInputDialog.getText(self, "Nazwa gracza offline", "Wprowadź domyślną nazwę użytkownika offline:", text="Player")
3296 if not ok or not username.strip():
3297 QMessageBox.warning(self, "Anulowano", "Nazwa gracza jest wymagana do uruchomienia gry offline.")
3298 self.status_label.setText("Uruchomienie anulowane (brak nazwy gracza).")
3299 return
3300 username = username.strip()
3301
3302 try:
3303 self.status_label.setText(f"Uruchamiam instancję: {instance_name}...")
3304 self.launcher.launch_game(instance_dir_path, username)
3305 self.status_label.setText(f"Uruchomiono instancję: {instance_name}")
3306
3307 except (FileNotFoundError, ValueError, RuntimeError, TimeoutError, Exception) as e:
3308 error_title = "Błąd uruchamiania"
3309 if isinstance(e, FileNotFoundError):
3310 error_title = "Brak wymaganych plików"
3311 elif isinstance(e, ValueError):
3312 error_title = "Błąd konfiguracji instancji"
3313 elif isinstance(e, TimeoutError):
3314 error_title = "Przekroczono czas oczekiwania"
3315
3316 logging.error(f"Błąd podczas uruchamiania instancji {instance_name}: {e}")
3317 QMessageBox.critical(self, error_title, f"Nie udało się uruchomić gry:\n{e}\nSprawdź logi launchera.")
3318 self.status_label.setText(f"Błąd uruchamiania instancji {instance_name}.")
3319
3320 def open_mod_browser(self):
3321 current_item = self.instance_list.currentItem()
3322 instance_dir_path = self.selected_instance_dir
3323
3324 if not current_item or not instance_dir_path:
3325 QMessageBox.warning(self, "Brak wybranej instancji", "Proszę wybrać instancję, dla której chcesz przeglądać mody.")
3326 self.mod_browser_button.setEnabled(False)
3327 return
3328
3329 instance_name = current_item.text()
3330
3331 if not Path(instance_dir_path).exists():
3332 QMessageBox.critical(self, "Błąd przeglądania modów", "Katalog wybranej instancji nie istnieje! Odświeżam listę.")
3333 self.update_instance_tiles()
3334 return
3335
3336 settings_path = Path(instance_dir_path) / "settings.json"
3337 if not settings_path.exists():
3338 QMessageBox.warning(self, "Ustawienia instancji", f"Brak pliku settings.json dla instancji '{instance_name}'. Nie można przeglądać modów.")
3339 self.mod_browser_button.setEnabled(False)
3340 return
3341
3342 try:
3343 with settings_path.open("r", encoding='utf-8') as f:
3344 settings = json.load(f)
3345 version_id = settings.get("version")
3346
3347 if not version_id:
3348 QMessageBox.warning(self, "Wersja nieznana", f"Wersja gry dla instancji '{instance_name}' nie została poprawnie skonfigurowana. Nie można przeglądać modów.")
3349 self.mod_browser_button.setEnabled(False)
3350 return
3351
3352 if self.launcher.current_download_thread or self.launcher.download_queue:
3353 QMessageBox.warning(self, "Pobieranie aktywne", "Inny proces pobierania jest aktywny. Proszę poczekać na jego zakończenie.")
3354 return
3355
3356 dialog = ModBrowserDialog(self.launcher, version_id, instance_dir_path, self)
3357 dialog.exec()
3358
3359 except json.JSONDecodeError as e:
3360 logging.error(f"Błąd odczytu settings.json dla instancji {instance_name}: {e}")
3361 QMessageBox.critical(self, "Błąd ładowania instancji", f"Nie udało się odczytać ustawień instancji '{instance_name}'.")
3362 except Exception as e:
3363 logging.error(f"Nieoczekiwany błąd podczas otwierania przeglądarki modów dla instancji {instance_name}: {e}")
3364 QMessageBox.critical(self, "Błąd przeglądania modów", f"Wystąpił nieoczekiwany błąd: {e}")
3365
3366 def set_offline_account(self):
3367 current_username = self.launcher.settings.get("default_account", "")
3368 username, ok = QInputDialog.getText(self, "Ustaw nazwę konta offline", "Wprowadź domyślną nazwę użytkownika offline:", text=current_username)
3369 if ok and username:
3370 username = username.strip()
3371 if username:
3372 self.launcher.settings["default_account"] = username
3373 self.launcher.save_settings()
3374 QMessageBox.information(self, "Ustawiono konto", f"Domyślne konto offline ustawione na: '{username}'.")
3375 logging.info(f"Ustawiono domyślne konto offline: {username}")
3376 else:
3377 self.launcher.settings["default_account"] = ""
3378 self.launcher.save_settings()
3379 QMessageBox.information(self, "Ustawiono konto", "Domyślne konto offline zostało zresetowane. Nazwa będzie pytana przy uruchomieniu lub użyta domyślna 'Player'.")
3380 logging.info("Domyślne konto offline zresetowane.")
3381
3382 def open_settings(self):
3383 dialog = QDialog(self)
3384 dialog.setWindowTitle("Ustawienia launchera")
3385 dialog.setMinimumWidth(400)
3386 layout = QVBoxLayout(dialog)
3387 layout.setSpacing(10)
3388
3389 layout.addWidget(QLabel("Motyw interfejsu:"))
3390 theme_combo = QComboBox()
3391 theme_combo.addItems(["Light", "Night"])
3392 theme_combo.setCurrentText(self.launcher.settings.get("theme", "Light"))
3393 layout.addWidget(theme_combo)
3394
3395 layout.addWidget(QLabel("Domyślna wersja Javy dla nowych instancji:"))
3396 java_combo = QComboBox()
3397 java_combo.addItem("Automatyczny wybór", userData="auto")
3398 sorted_java_versions = sorted(self.launcher.java_versions, key=lambda x: self.launcher.get_java_version_from_path(x[0]) or 0, reverse=True)
3399 for java_path, version in sorted_java_versions:
3400 major_v = self.launcher.get_java_version_from_path(java_path)
3401 java_combo.addItem(f"{version} (Java {major_v}) - {java_path}", userData=java_path)
3402
3403 current_java_setting = self.launcher.settings.get("java_path", "auto")
3404 if current_java_setting.lower() == 'auto':
3405 java_combo.setCurrentText("Automatyczny wybór")
3406 else:
3407 found_index = java_combo.findData(current_java_setting)
3408 if found_index != -1:
3409 java_combo.setCurrentIndex(found_index)
3410 else:
3411 custom_item_text = f"Zapisana ścieżka: {current_java_setting} (Nieznana wersja)"
3412 java_combo.addItem(custom_item_text, userData=current_java_setting)
3413 java_combo.setCurrentIndex(java_combo.count() - 1)
3414 logging.warning(f"Zapisana ścieżka Javy w ustawieniach nie znaleziona wśród automatycznie wykrytych: {current_java_setting}. Dodano jako opcję niestandardową.")
3415
3416 layout.addWidget(java_combo)
3417
3418 layout.addWidget(QLabel("Domyślna pamięć RAM (np. 4G, 2048M):"))
3419 ram_input = QLineEdit(self.launcher.settings.get("ram", DEFAULT_SETTINGS["ram"]))
3420 layout.addWidget(ram_input)
3421
3422 layout.addWidget(QLabel("Domyślne dodatkowe argumenty JVM:"))
3423 jvm_args_input = QLineEdit(self.launcher.settings.get("jvm_args", DEFAULT_SETTINGS["jvm_args"]))
3424 layout.addWidget(jvm_args_input)
3425
3426 fullscreen_check = QCheckBox("Domyślnie pełny ekran")
3427 fullscreen_check.setChecked(self.launcher.settings.get("fullscreen", DEFAULT_SETTINGS["fullscreen"]))
3428 layout.addWidget(fullscreen_check)
3429
3430 layout.addWidget(QLabel("Domyślna rozdzielczość (np. 1280x720):"))
3431 resolution_input = QLineEdit(self.launcher.settings.get("resolution", DEFAULT_SETTINGS["resolution"]))
3432 layout.addWidget(resolution_input)
3433
3434 current_account_label = QLabel(f"Domyślne konto offline: {self.launcher.settings.get('default_account', 'Brak')}")
3435 layout.addWidget(current_account_label)
3436
3437 button_layout = QHBoxLayout()
3438 save_button = QPushButton("Zapisz ustawienia")
3439 save_button.clicked.connect(dialog.accept)
3440
3441 cancel_button = QPushButton("Anuluj")
3442 cancel_button.clicked.connect(dialog.reject)
3443
3444 button_layout.addStretch(1)
3445 button_layout.addWidget(save_button)
3446 button_layout.addWidget(cancel_button)
3447 layout.addLayout(button_layout)
3448
3449 if dialog.exec():
3450 selected_theme = theme_combo.currentText()
3451
3452 selected_java_index = java_combo.currentIndex()
3453 selected_java_path_data = java_combo.itemData(selected_java_index)
3454 selected_java_path_to_save = selected_java_path_data if selected_java_path_data is not None else "auto"
3455
3456 selected_ram = ram_input.text().strip().upper()
3457 selected_jvm_args = jvm_args_input.text().strip()
3458 selected_fullscreen = fullscreen_check.isChecked()
3459 selected_resolution = resolution_input.text().strip()
3460
3461 if not re.match(r"^\d+[MG]$", selected_ram):
3462 QMessageBox.warning(dialog, "Nieprawidłowy format RAM", "Nieprawidłowy format pamięci RAM. Ustawienia nie zostały zapisane.")
3463 return
3464
3465 if not re.match(r"^\d+x\d+$", selected_resolution):
3466 QMessageBox.warning(dialog, "Nieprawidłowy format rozdzielczości", "Nieprawidłowy format rozdzielczości. Ustawienia nie zostały zapisane.")
3467 return
3468
3469 if selected_java_path_to_save != 'auto' and not Path(selected_java_path_to_save).exists():
3470 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.")
3471 return
3472
3473 self.launcher.settings["theme"] = selected_theme
3474 self.launcher.settings["java_path"] = selected_java_path_to_save
3475 self.launcher.settings["ram"] = selected_ram
3476 self.launcher.settings["jvm_args"] = selected_jvm_args
3477 self.launcher.settings["fullscreen"] = selected_fullscreen
3478 self.launcher.settings["resolution"] = selected_resolution
3479
3480 self.launcher.save_settings()
3481
3482 self.apply_theme()
3483 logging.info("Ustawienia launchera zaktualizowane.")
3484 QMessageBox.information(self, "Sukces", "Ustawienia zostały zapisane.")
3485
3486 def closeEvent(self, event):
3487 if self.launcher.progress_dialog and self.launcher.progress_dialog.isVisible():
3488 reply = QMessageBox.question(self, "Zamknąć?",
3489 "Pobieranie wciąż trwa. Czy na pewno chcesz zamknąć launcher i anulować pobieranie?",
3490 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
3491 if reply == QMessageBox.StandardButton.Yes:
3492 self.launcher.progress_dialog.cancel_downloads()
3493 event.accept()
3494 else:
3495 event.ignore()
3496 else:
3497 event.accept()
3498
3499if __name__ == "__main__":
3500 signal.signal(signal.SIGINT, signal.SIG_DFL)
3501
3502 app = QApplication(sys.argv)
3503 window = LauncherWindow()
3504 window.show()
3505 sys.exit(app.exec())