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