Skip to content

Commit 5c82700

Browse files
committed
feat: Implement Java validation and download
1 parent 326abc6 commit 5c82700

File tree

4 files changed

+231
-17
lines changed

4 files changed

+231
-17
lines changed

minecraft_server_gui.py

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717

1818
from gui.widgets import CollapsiblePane
1919
from utils.constants import *
20-
from utils.helpers import format_size, get_folder_size, get_local_ip, get_server_port
21-
from utils.api_client import fetch_player_avatar_image, fetch_player_uuid, download_server_jar, get_server_versions, fetch_username_from_uuid, get_forge_versions
20+
from utils.helpers import format_size, get_folder_size, get_local_ip, get_server_port, get_java_version, get_required_java_version
21+
from utils.api_client import fetch_player_avatar_image, fetch_player_uuid, download_server_jar, get_server_versions, fetch_username_from_uuid, get_forge_versions, download_jre
2222
from server.server_handler import ServerHandler
2323
from server.config_manager import ConfigManager
2424

@@ -328,7 +328,7 @@ def _create_setup_wizard(self):
328328
self.vanilla_version_frame = ctk.CTkFrame(self.version_options_container, fg_color="transparent")
329329
ctk.CTkLabel(self.vanilla_version_frame, text="2. Select Minecraft Version", font=ctk.CTkFont(weight="bold")).pack(anchor='w', pady=(10,5))
330330
self.server_version_var = tk.StringVar()
331-
self.version_menu = ctk.CTkComboBox(self.vanilla_version_frame, variable=self.server_version_var, values=["Loading..."], state="disabled")
331+
self.version_menu = ctk.CTkComboBox(self.vanilla_version_frame, variable=self.server_version_var, values=["Loading..."], state="disabled", command=self._check_java_compatibility)
332332
self.version_menu.pack(fill=tk.X, pady=5)
333333

334334
# Steps 2 & 3 (Forge): Forge-specific widgets
@@ -356,9 +356,20 @@ def _create_setup_wizard(self):
356356
self.server_name_var = tk.StringVar()
357357
self.server_name_entry = ctk.CTkEntry(self.install_frame, textvariable=self.server_name_var)
358358
self.server_name_entry.pack(fill=tk.X, pady=5)
359+
360+
# --- Java Validation ---
361+
self.java_status_frame = ctk.CTkFrame(self.install_frame, fg_color="gray20")
362+
self.java_status_frame.pack(fill=tk.X, pady=10, ipady=5)
363+
self.java_status_label = ctk.CTkLabel(self.java_status_frame, text="Java Status: Checking...", anchor="w")
364+
self.java_status_label.pack(side=tk.LEFT, padx=10)
359365

366+
self.download_jre_var = tk.BooleanVar(value=False)
367+
self.download_jre_checkbox = ctk.CTkCheckBox(self.install_frame, text="Automatically download a compatible Java runtime", variable=self.download_jre_var)
368+
# Will be packed later if needed
369+
360370
self.eula_accepted_var = tk.BooleanVar(value=False)
361-
ctk.CTkCheckBox(self.install_frame, text="I agree to the Minecraft EULA (minecraft.net/eula)", variable=self.eula_accepted_var).pack(fill=tk.X, pady=10)
371+
self.eula_checkbox = ctk.CTkCheckBox(self.install_frame, text="I agree to the Minecraft EULA (minecraft.net/eula)", variable=self.eula_accepted_var)
372+
self.eula_checkbox.pack(fill=tk.X, pady=10)
362373

363374
# --- Widgets for "Use Existing Server" ---
364375
ctk.CTkLabel(self.existing_frame, text="1. Choose Existing Server Location", font=ctk.CTkFont(weight="bold")).pack(anchor='w', pady=(10,5))
@@ -370,7 +381,7 @@ def _create_setup_wizard(self):
370381

371382
# --- Action Button & Progress ---
372383
self.action_button = ctk.CTkButton(setup_frame, text="Download and Install Server", command=self._start_setup_action)
373-
self.action_button.pack(pady=(30, 10), ipady=10, fill=tk.X)
384+
self.action_button.pack(pady=(20, 10), ipady=10, fill=tk.X)
374385
self.progress_bar = ctk.CTkProgressBar(setup_frame)
375386
self.progress_bar.set(0)
376387
self.progress_bar.pack(fill=tk.X, pady=5)
@@ -383,6 +394,36 @@ def _create_setup_wizard(self):
383394
self._toggle_setup_view()
384395
self._on_server_type_change()
385396

397+
def _check_java_compatibility(self, *args):
398+
mc_version_str = self.server_version_var.get()
399+
if self.server_type_var.get().lower() == 'forge':
400+
mc_version_str = self.forge_mc_version_var.get()
401+
402+
if not mc_version_str or "Loading" in mc_version_str or "Error" in mc_version_str:
403+
self.java_status_label.configure(text="Java Status: Select a Minecraft version first")
404+
return
405+
406+
required_version = get_required_java_version(mc_version_str)
407+
installed_version = get_java_version()
408+
409+
if installed_version is None:
410+
status_text = f"❌ Java Not Found (Required: Java {required_version}+)"
411+
status_color = "red"
412+
self.download_jre_var.set(True)
413+
self.download_jre_checkbox.pack(fill=tk.X, pady=5, before=self.eula_checkbox)
414+
elif installed_version < required_version:
415+
status_text = f"⚠️ Wrong Version: Java {installed_version} found (Required: Java {required_version}+)"
416+
status_color = "orange"
417+
self.download_jre_var.set(True)
418+
self.download_jre_checkbox.pack(fill=tk.X, pady=5, before=self.eula_checkbox)
419+
else:
420+
status_text = f"✅ Java {installed_version} Found (Compatible)"
421+
status_color = "green"
422+
self.download_jre_var.set(False)
423+
self.download_jre_checkbox.pack_forget()
424+
425+
self.java_status_label.configure(text=status_text, text_color=status_color)
426+
386427
def _on_server_type_change(self, *args):
387428
server_type = self.server_type_var.get().lower()
388429

@@ -400,6 +441,8 @@ def _on_server_type_change(self, *args):
400441
self.location_step_label.configure(text="3. Choose Parent Directory")
401442
self.name_step_label.configure(text="4. Name Server Folder")
402443
self._update_server_versions()
444+
445+
self._check_java_compatibility()
403446

404447
def _update_forge_versions(self):
405448
self.forge_mc_version_var.set("Loading...")
@@ -420,6 +463,7 @@ def update_ui():
420463
else:
421464
self.forge_mc_version_var.set("Error")
422465
self.forge_version_var.set("Error")
466+
self._check_java_compatibility()
423467

424468
if hasattr(self, 'master'):
425469
self.master.after(0, update_ui)
@@ -435,11 +479,13 @@ def _on_forge_mc_version_selected(self, mc_version):
435479
else:
436480
self.forge_version_menu.configure(values=[], state="disabled")
437481
self.forge_version_var.set("N/A")
482+
self._check_java_compatibility()
438483

439484
def _update_server_versions(self, *args):
440485
server_type = self.server_type_var.get()
441486
if server_type.lower() == "forge":
442-
return # No need to fetch versions for Forge this way
487+
self._check_java_compatibility()
488+
return
443489

444490
self.server_version_var.set("Loading...")
445491
self.version_menu.configure(state=tk.DISABLED)
@@ -455,6 +501,7 @@ def update_ui():
455501
self.server_version_var.set(versions[0])
456502
else:
457503
self.server_version_var.set("Error fetching versions")
504+
self._check_java_compatibility()
458505

459506
if hasattr(self, 'master'):
460507
self.master.after(0, update_ui)
@@ -580,6 +627,32 @@ def _detect_server_version(self, server_path):
580627

581628
def _perform_server_installation(self):
582629
try:
630+
# --- Java Handling ---
631+
if self.download_jre_var.get():
632+
mc_version_str = self.server_version_var.get()
633+
if self.server_type_var.get().lower() == 'forge':
634+
mc_version_str = self.forge_mc_version_var.get()
635+
636+
required_java = get_required_java_version(mc_version_str)
637+
self.master.after(0, self.status_label.configure, {'text': f"Downloading compatible Java JRE (Version {required_java})..."})
638+
639+
def progress_callback(p):
640+
self.master.after(0, self.progress_bar.set, p / 100)
641+
642+
jre_path = download_jre(java_version=required_java, progress_callback=progress_callback)
643+
644+
if not jre_path:
645+
raise Exception(f"Failed to download the required Java JRE. Please install Java {required_java} manually and try again.")
646+
647+
# On Windows, the executable is in /bin/java.exe
648+
java_exe_path = os.path.join(jre_path, 'bin', 'java.exe')
649+
if not os.path.exists(java_exe_path):
650+
raise Exception(f"Could not find java.exe in the downloaded JRE folder.")
651+
652+
self.java_path_var.set(java_exe_path)
653+
self.master.after(0, self.status_label.configure, {'text': "Java JRE downloaded successfully."})
654+
self.master.after(0, self.progress_bar.set, 0)
655+
583656
server_type = self.server_type_var.get().lower()
584657
parent_dir = self.install_location_var.get()
585658
server_folder_name = self.server_name_var.get()
@@ -595,7 +668,7 @@ def _perform_server_installation(self):
595668
os.makedirs(install_path, exist_ok=True)
596669

597670
# Initialize ServerHandler here to use its methods
598-
temp_server_handler = ServerHandler(install_path, server_type, "1", "2", "G", lambda msg, level: self.master.after(0, self.log_to_console, msg, level))
671+
temp_server_handler = ServerHandler(install_path, server_type, "1", "2", "G", lambda msg, level: self.master.after(0, self.log_to_console, msg, level), java_path=self.java_path_var.get())
599672

600673
if server_type == 'forge':
601674
forge_version = self.forge_version_var.get()
@@ -627,7 +700,7 @@ def install_logic():
627700
self.master.after(0, self.status_label.configure, {'text': "First-time server run to generate files..."})
628701
self.master.after(0, self.progress_bar.set, 0)
629702

630-
initial_run_command = ['java', '-jar', jar_name, '--nogui']
703+
initial_run_command = [self.java_path_var.get(), '-jar', jar_name, '--nogui']
631704
process = subprocess.Popen(initial_run_command, cwd=install_path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0)
632705
try:
633706
stdout, stderr = process.communicate(timeout=600)

server/server_handler.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@
99
from utils.api_client import download_file_from_url, download_and_extract_zip
1010

1111
class ServerHandler:
12-
def __init__(self, server_path, server_type, ram_min, ram_max, ram_unit, output_callback):
12+
def __init__(self, server_path, server_type, ram_min, ram_max, ram_unit, output_callback, java_path="java"):
1313
self.server_path = server_path
1414
self.server_type = server_type
1515
self.ram_min = ram_min
1616
self.ram_max = ram_max
1717
self.ram_unit = ram_unit
1818
self.output_callback = output_callback
19+
self.java_path = java_path
1920
self.server_process = None
2021
self.tunnel_process = None
2122
self.public_url = None
@@ -44,7 +45,7 @@ def install_forge_server(self, forge_version, minecraft_version, progress_callba
4445

4546
# Run the installer
4647
try:
47-
install_command = ["java", "-jar", installer_path, "--installServer"]
48+
install_command = [self.java_path, "-jar", installer_path, "--installServer"]
4849
process = subprocess.Popen(install_command, cwd=self.server_path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0)
4950

5051
# Log stdout and stderr from the installer
@@ -100,7 +101,7 @@ def start(self):
100101
threading.Thread(target=self._run_server, args=(command, env), daemon=True).start()
101102

102103
def _get_start_command(self):
103-
java_path = "java"
104+
java_path = self.java_path
104105
run_script = None
105106

106107
# Universal check for startup scripts

utils/api_client.py

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,11 @@ def download_file_from_url(download_url, save_path, progress_callback):
139139
logging.error(f"Failed to download file: {e}")
140140
return False
141141

142-
def download_and_extract_zip(url, extract_to_dir, progress_callback):
143-
"""Downloads a zip file and extracts its contents."""
142+
def download_and_extract_zip(url, extract_to_dir, progress_callback, contains_single_folder=True):
143+
"""
144+
Downloads a zip file and extracts its contents.
145+
If contains_single_folder is True, it moves the contents of the single top-level folder up one level.
146+
"""
144147
os.makedirs(extract_to_dir, exist_ok=True)
145148
zip_path = os.path.join(extract_to_dir, 'temp.zip')
146149

@@ -152,17 +155,99 @@ def download_and_extract_zip(url, extract_to_dir, progress_callback):
152155
logging.info(f"Extracting {zip_path} to {extract_to_dir}")
153156
try:
154157
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
155-
zip_ref.extractall(extract_to_dir)
158+
if contains_single_folder:
159+
# Extract to a temporary subdirectory to handle the nested folder structure
160+
temp_extract_dir = os.path.join(extract_to_dir, "temp_extract")
161+
os.makedirs(temp_extract_dir, exist_ok=True)
162+
zip_ref.extractall(temp_extract_dir)
163+
164+
# Identify the single folder inside the temp directory
165+
extracted_items = os.listdir(temp_extract_dir)
166+
if len(extracted_items) == 1 and os.path.isdir(os.path.join(temp_extract_dir, extracted_items[0])):
167+
single_folder_path = os.path.join(temp_extract_dir, extracted_items[0])
168+
# Move each item from the single folder to the final destination
169+
for item_name in os.listdir(single_folder_path):
170+
source_item = os.path.join(single_folder_path, item_name)
171+
dest_item = os.path.join(extract_to_dir, item_name)
172+
os.rename(source_item, dest_item)
173+
# Clean up the temporary extraction directory
174+
os.rmdir(single_folder_path)
175+
os.rmdir(temp_extract_dir)
176+
else:
177+
# If it's not a single folder, move items directly
178+
for item_name in extracted_items:
179+
source_item = os.path.join(temp_extract_dir, item_name)
180+
dest_item = os.path.join(extract_to_dir, item_name)
181+
os.rename(source_item, dest_item)
182+
os.rmdir(temp_extract_dir)
183+
184+
else: # Original behavior
185+
zip_ref.extractall(extract_to_dir)
186+
156187
logging.info("Extraction complete.")
157-
except zipfile.BadZipFile:
158-
logging.error("Failed to extract: The file is not a valid zip file.")
188+
except (zipfile.BadZipFile, IOError) as e:
189+
logging.error(f"Failed to extract: {e}")
159190
return False
160191
finally:
161192
if os.path.exists(zip_path):
162193
os.remove(zip_path)
163194

164195
return True
165196

197+
def download_jre(java_version, os_type="windows", arch="x64", progress_callback=None):
198+
"""
199+
Downloads a portable JRE from Adoptium API.
200+
Returns the path to the extracted JRE folder or None on failure.
201+
"""
202+
if progress_callback is None:
203+
progress_callback = lambda p: None
204+
205+
try:
206+
api_url = f"https://api.adoptium.net/v3/assets/latest/{java_version}/hotspot?vendor=eclipse"
207+
response = requests.get(api_url, timeout=15)
208+
response.raise_for_status()
209+
releases = response.json()
210+
211+
binary_info = None
212+
for release in releases:
213+
if release['binary']['os'] == os_type and release['binary']['architecture'] == arch and release['binary']['image_type'] == 'jre':
214+
binary_info = release['binary']
215+
break
216+
217+
if not binary_info:
218+
logging.error(f"Could not find a matching JRE for Java {java_version}, OS: {os_type}, Arch: {arch}")
219+
return None
220+
221+
download_url = binary_info['package']['link']
222+
jre_name = f"jre-{java_version}"
223+
extract_path = os.path.join(os.getcwd(), "jre_temp") # Temp extraction folder
224+
final_path = os.path.join(os.getcwd(), jre_name) # Final destination
225+
226+
if os.path.exists(final_path):
227+
logging.info(f"JRE for Java {java_version} already exists. Skipping download.")
228+
return final_path
229+
230+
logging.info(f"Downloading JRE from: {download_url}")
231+
232+
# The JRE zip from Adoptium contains a single top-level folder, so we need to handle that.
233+
if download_and_extract_zip(download_url, extract_path, progress_callback, contains_single_folder=True):
234+
os.rename(extract_path, final_path)
235+
logging.info(f"JRE successfully downloaded and extracted to {final_path}")
236+
return final_path
237+
else:
238+
logging.error("Failed to download and extract JRE.")
239+
# Clean up failed extraction
240+
if os.path.exists(extract_path):
241+
import shutil
242+
shutil.rmtree(extract_path)
243+
return None
244+
245+
except requests.RequestException as e:
246+
logging.error(f"API Error fetching JRE data: {e}")
247+
return None
248+
except Exception as e:
249+
logging.error(f"An unexpected error occurred during JRE download: {e}")
250+
return None
166251

167252
if __name__ == '__main__':
168253
print("Fetching Forge versions...")

0 commit comments

Comments
 (0)