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