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