Skip to content

Commit 326abc6

Browse files
committed
implement tunnel feature in server handler
- Created a .gitignore file to exclude unnecessary files and directories. - Updated the GUI to include controls for managing the tunnel and displaying the public IP. - Added functionality to download and extract the bore executable if not already present. - Updated README.md to document the new "Make Public" feature and its usage.
1 parent 5942e77 commit 326abc6

File tree

6 files changed

+297
-3
lines changed

6 files changed

+297
-3
lines changed

.gitignore

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Python
2+
__pycache__/
3+
*.pyc
4+
*.pyo
5+
*.pyd
6+
7+
# Virtual Environment
8+
venv/
9+
.venv/
10+
11+
# Downloaded Dependencies
12+
bore.exe
13+
14+
# Environment Variables
15+
.env
16+
17+
# IDE / Editor specific
18+
.vscode/
19+
.idea/
20+
21+
# Build artifacts
22+
build/
23+
dist/
24+
*.egg-info/
25+
26+
# Log files
27+
*.log

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ A user-friendly desktop application for installing, managing, and running local
2525
- **Smart & Automated**:
2626
- **Automatic EULA Handling**: Automatically detects and accepts the EULA on the first run of a new server.
2727
- **Smart Startup**: Automatically uses the correct startup script (`run.bat`/`run.sh`) for Forge servers.
28+
- **Make Public (Experimental)**:
29+
- Expose your local server to the internet with a single click, allowing friends to join from anywhere.
30+
- **Disclaimer**: This feature uses a free, public tunneling service. As such, it may introduce lag and is not guaranteed to be stable. The public address will be different each time you start the tunnel.
31+
- Powered by the open-source tool **[bore](https://github.com/ekzhang/bore)** by **ekzhang**.
2832
- **Customization & Settings**:
2933
- **Custom RAM Allocation**: Easily set the minimum and maximum RAM for your server.
3034
- **Configuration Saving**: Remembers your server path and settings between sessions.

minecraft_server_gui.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
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
20+
from utils.helpers import format_size, get_folder_size, get_local_ip, get_server_port
2121
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
2222
from server.server_handler import ServerHandler
2323
from server.config_manager import ConfigManager
@@ -89,9 +89,13 @@ def on_close(self):
8989

9090
if response is not None:
9191
self.log_to_console("Stopping server before exiting...\n", "info")
92+
if self.server_handler.is_tunnel_running():
93+
self.server_handler.stop_tunnel()
9294
self.server_handler.stop()
9395
self._wait_for_server_to_stop_and_destroy()
9496
else:
97+
if hasattr(self, 'server_handler') and self.server_handler.is_tunnel_running():
98+
self.server_handler.stop_tunnel()
9599
self.master.destroy()
96100

97101
def _wait_for_server_to_stop_and_destroy(self):
@@ -769,6 +773,18 @@ def add_info_row(label_text, var_text):
769773
self.copy_ip_button = ctk.CTkButton(ip_frame, text="Copy", width=60, command=self._copy_ip_to_clipboard)
770774
self.copy_ip_button.pack(side=tk.LEFT, padx=5)
771775

776+
# --- Public IP (Tunnel) ---
777+
row_idx = info_grid.grid_size()[1]
778+
ctk.CTkLabel(info_grid, text="Public IP:", font=ctk.CTkFont(weight="bold")).grid(row=row_idx, column=0, sticky='w', padx=(0,10), pady=(5,0))
779+
public_ip_frame = ctk.CTkFrame(info_grid, fg_color="transparent")
780+
public_ip_frame.grid(row=row_idx, column=1, sticky='ew', pady=(5,0))
781+
self.public_ip_label = ctk.CTkLabel(public_ip_frame, text="Tunnel is offline")
782+
self.public_ip_label.pack(side=tk.LEFT, anchor='w')
783+
self.tunnel_button = ctk.CTkButton(public_ip_frame, text="Make Public", width=100, command=self._toggle_tunnel)
784+
self.tunnel_button.pack(side=tk.LEFT, padx=5)
785+
self.tunnel_info_button = ctk.CTkButton(public_ip_frame, text="?", width=28, command=self.show_tunnel_info)
786+
self.tunnel_info_button.pack(side=tk.LEFT, padx=(0, 5))
787+
772788
# --- Bottom Panel for Console ---
773789
console_card = ctk.CTkFrame(parent_frame)
774790
console_card.grid(row=1, column=0, sticky='nsew')
@@ -792,6 +808,45 @@ def add_info_row(label_text, var_text):
792808
self.console_send_btn = ctk.CTkButton(command_frame, text="Send", width=70, command=self.send_command_from_console_button)
793809
self.console_send_btn.grid(row=0, column=1)
794810

811+
def show_tunnel_info(self):
812+
"""Displays a pop-up window with information about the tunnel feature."""
813+
info_win = ctk.CTkToplevel(self.master)
814+
info_win.title("About the 'Make Public' Feature")
815+
info_win.transient(self.master)
816+
info_win.grab_set()
817+
818+
info_text = """
819+
About the 'Make Public' Feature
820+
821+
This experimental feature uses a free, public
822+
tunneling service to expose your local server
823+
to the internet for others to join.
824+
825+
Important Notes:
826+
• A new random address is generated each time.
827+
• Connection may experience higher latency (lag).
828+
829+
Acknowledgements:
830+
This is powered by the open-source project 'bore'
831+
created by ekzhang.
832+
"""
833+
834+
ctk.CTkLabel(info_win, text=info_text, justify=tk.LEFT, wraplength=350).pack(padx=20, pady=20)
835+
ctk.CTkButton(info_win, text="OK", command=info_win.destroy).pack(pady=10, padx=20)
836+
837+
# Center the window
838+
self.master.update_idletasks()
839+
master_x = self.master.winfo_x()
840+
master_y = self.master.winfo_y()
841+
master_w = self.master.winfo_width()
842+
master_h = self.master.winfo_height()
843+
win_w = info_win.winfo_width()
844+
win_h = info_win.winfo_height()
845+
x = master_x + (master_w - win_w) // 2
846+
y = master_y + (master_h - win_h) // 2
847+
info_win.geometry(f"+{x}+{y}")
848+
info_win.focus()
849+
795850
def _on_ram_slider_change(self, value):
796851
# Update the label as the slider moves
797852
max_ram = int(value)
@@ -815,6 +870,17 @@ def _copy_ip_to_clipboard(self):
815870
self.master.clipboard_append(ip)
816871
self.log_to_console(f"Copied '{ip}' to clipboard.\n", "success")
817872

873+
def _toggle_tunnel(self):
874+
if self.server_handler.is_tunnel_running():
875+
self.server_handler.stop_tunnel()
876+
self.tunnel_button.configure(text="Make Public")
877+
self.public_ip_label.configure(text="Tunnel is offline")
878+
else:
879+
port = get_server_port(self.server_properties_path)
880+
self.server_handler.start_tunnel(port)
881+
self.tunnel_button.configure(text="Stop Tunnel")
882+
self.public_ip_label.configure(text="Starting tunnel...")
883+
818884
def _update_dashboard_info(self):
819885
if not self.server_path: return
820886
properties = {}
@@ -1115,6 +1181,8 @@ def start_server_thread(self):
11151181
self._update_server_status_display()
11161182

11171183
def stop_server(self):
1184+
if self.server_handler.is_tunnel_running():
1185+
self.server_handler.stop_tunnel()
11181186
self.server_handler.stop()
11191187
self.stop_button.configure(state=tk.DISABLED)
11201188
self._update_server_status_display()
@@ -1137,6 +1205,17 @@ def process_server_output(self, line, level):
11371205
self._detect_type_from_log(clean_line)
11381206
return
11391207

1208+
if clean_line.startswith("PUBLIC_URL:"):
1209+
url = clean_line.replace("PUBLIC_URL:", "").strip()
1210+
self.public_ip_label.configure(text=url)
1211+
self.tunnel_button.configure(text="Stop Tunnel")
1212+
return
1213+
1214+
if "PUBLIC_URL_STOPPED" in clean_line:
1215+
self.public_ip_label.configure(text="Tunnel is offline")
1216+
self.tunnel_button.configure(text="Make Public")
1217+
return
1218+
11401219
if self.expecting_player_list_next_line:
11411220
self.players_connected = [p.strip() for p in clean_line.split(',') if p.strip()]
11421221
self._refresh_players_display()

server/server_handler.py

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import glob
55
import sys
66
import logging
7-
from utils.api_client import download_file_from_url
7+
import re
8+
9+
from utils.api_client import download_file_from_url, download_and_extract_zip
810

911
class ServerHandler:
1012
def __init__(self, server_path, server_type, ram_min, ram_max, ram_unit, output_callback):
@@ -15,10 +17,15 @@ def __init__(self, server_path, server_type, ram_min, ram_max, ram_unit, output_
1517
self.ram_unit = ram_unit
1618
self.output_callback = output_callback
1719
self.server_process = None
20+
self.tunnel_process = None
21+
self.public_url = None
22+
self.tunnel_thread = None
23+
self.stop_tunnel_event = threading.Event()
1824
self.server_fully_started = False
1925
self.server_stopping = False
2026
self.server_running = False
2127

28+
2229
def install_forge_server(self, forge_version, minecraft_version, progress_callback):
2330
"""Downloads and installs a Forge server."""
2431
# This is an example URL structure. You'll need to get the correct URLs.
@@ -228,3 +235,143 @@ def force_stop_state(self):
228235
self.server_process = None
229236
self.server_running = False
230237
self.server_stopping = False
238+
239+
def get_bore_path(self):
240+
"""Returns the expected path to the bore executable."""
241+
return os.path.join(os.getcwd(), "bore.exe")
242+
243+
def is_bore_downloaded(self):
244+
"""Checks if bore.exe exists."""
245+
return os.path.exists(self.get_bore_path())
246+
247+
def _download_bore(self):
248+
"""Downloads and extracts bore.exe."""
249+
# URL for bore v0.5.0 for Windows
250+
bore_url = "https://github.com/ekzhang/bore/releases/download/v0.5.0/bore-v0.5.0-x86_64-pc-windows-msvc.zip"
251+
extract_dir = os.getcwd()
252+
253+
self.output_callback("Downloading bore executable (this will only happen once)...\n", "info")
254+
255+
# Simple progress display via output_callback. A real progress bar would require more GUI integration.
256+
def progress_callback(p):
257+
self.output_callback(f"\rDownload progress: {p}%", "info")
258+
259+
if download_and_extract_zip(bore_url, extract_dir, progress_callback):
260+
self.output_callback("\nBore downloaded successfully.\n", "info")
261+
return True
262+
else:
263+
self.output_callback("\nFailed to download bore.\n", "error")
264+
return False
265+
266+
def start_tunnel(self, port):
267+
"""Starts the tunnel using bore."""
268+
if self.is_tunnel_running():
269+
self.output_callback("Tunnel is already running.\n", "warning")
270+
return
271+
272+
if not self.is_bore_downloaded():
273+
self.output_callback("Bore executable not found. Attempting to download...\n", "info")
274+
if not self._download_bore():
275+
self.output_callback("Could not start tunnel due to download failure.\n", "error")
276+
return
277+
278+
bore_path = self.get_bore_path()
279+
command = [bore_path, "local", str(port), "--to", "bore.pub"]
280+
281+
self.output_callback("Starting tunnel...\n", "info")
282+
283+
self.stop_tunnel_event.clear()
284+
self.tunnel_thread = threading.Thread(target=self._run_tunnel, args=(command,), daemon=True)
285+
self.tunnel_thread.start()
286+
287+
def _run_tunnel(self, command):
288+
"""Runs the bore process and captures its output."""
289+
try:
290+
self.tunnel_process = subprocess.Popen(
291+
command,
292+
stdout=subprocess.PIPE,
293+
stderr=subprocess.STDOUT,
294+
text=True,
295+
encoding='utf-8',
296+
errors='replace',
297+
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
298+
)
299+
300+
self.output_callback("Tunnel process started. Waiting for public URL...\n", "info")
301+
302+
# Read output line-by-line to find the URL
303+
for line in iter(self.tunnel_process.stdout.readline, ''):
304+
if self.stop_tunnel_event.is_set():
305+
break
306+
307+
line_stripped = line.strip()
308+
if not line_stripped:
309+
continue
310+
311+
self.output_callback(f"[Tunnel] {line_stripped}\n", "normal")
312+
313+
# Clean the line of ANSI escape codes for reliable parsing
314+
clean_line = re.sub(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])', '', line_stripped)
315+
316+
if "listening at" in clean_line and not self.public_url:
317+
try:
318+
url_part = clean_line.split("listening at")[1].strip()
319+
self.public_url = url_part
320+
self.output_callback(f"PUBLIC_URL:{self.public_url}\n", "success")
321+
# URL found, break the reading loop and move to waiting
322+
break
323+
except IndexError:
324+
self.output_callback("Could not parse public URL from tunnel output.\n", "warning")
325+
326+
# If we are here, we either found the URL, the process died, or we are stopping
327+
if self.stop_tunnel_event.is_set():
328+
# We were asked to stop while searching for URL, which is handled in stop_tunnel
329+
pass
330+
elif self.public_url:
331+
# URL was found, now just wait for the process to end for any reason.
332+
# This will block the thread until stop_tunnel() is called or the process dies.
333+
self.tunnel_process.wait()
334+
else:
335+
# Loop finished but we have no URL, means process died before giving one
336+
self.output_callback("Tunnel process exited before providing a public URL.\n", "warning")
337+
338+
except FileNotFoundError:
339+
self.output_callback(f"Error: bore.exe not found at {command[0]}\n", "error")
340+
except Exception as e:
341+
self.output_callback(f"An error occurred while running the tunnel: {e}\n", "error")
342+
finally:
343+
# This block now runs only when the process has truly terminated
344+
self.tunnel_process = None
345+
self.public_url = None
346+
if not self.stop_tunnel_event.is_set():
347+
self.output_callback("Tunnel stopped unexpectedly.\n", "error")
348+
349+
# Always notify GUI to reset state
350+
self.output_callback("PUBLIC_URL_STOPPED\n", "info")
351+
352+
def stop_tunnel(self):
353+
"""Stops the tunnel process."""
354+
if not self.is_tunnel_running():
355+
return
356+
357+
self.output_callback("Stopping tunnel...\n", "info")
358+
self.stop_tunnel_event.set()
359+
if self.tunnel_process:
360+
self.tunnel_process.terminate()
361+
try:
362+
self.tunnel_process.wait(timeout=5)
363+
except subprocess.TimeoutExpired:
364+
self.tunnel_process.kill()
365+
366+
if self.tunnel_thread:
367+
self.tunnel_thread.join(timeout=2)
368+
369+
self.tunnel_process = None
370+
self.tunnel_thread = None
371+
self.public_url = None
372+
self.output_callback("Tunnel stopped.\n", "info")
373+
self.output_callback("PUBLIC_URL_STOPPED\n", "info")
374+
375+
def is_tunnel_running(self):
376+
"""Checks if the tunnel process is running."""
377+
return self.tunnel_thread and self.tunnel_thread.is_alive()

utils/api_client.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import xml.etree.ElementTree as ET
88
from collections import defaultdict
99
import re
10+
import zipfile
1011

1112
# Configure logging
1213
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
@@ -138,6 +139,31 @@ def download_file_from_url(download_url, save_path, progress_callback):
138139
logging.error(f"Failed to download file: {e}")
139140
return False
140141

142+
def download_and_extract_zip(url, extract_to_dir, progress_callback):
143+
"""Downloads a zip file and extracts its contents."""
144+
os.makedirs(extract_to_dir, exist_ok=True)
145+
zip_path = os.path.join(extract_to_dir, 'temp.zip')
146+
147+
logging.info(f"Downloading zip from {url} to {zip_path}")
148+
if not download_file_from_url(url, zip_path, progress_callback):
149+
logging.error("Failed to download the zip file.")
150+
return False
151+
152+
logging.info(f"Extracting {zip_path} to {extract_to_dir}")
153+
try:
154+
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
155+
zip_ref.extractall(extract_to_dir)
156+
logging.info("Extraction complete.")
157+
except zipfile.BadZipFile:
158+
logging.error("Failed to extract: The file is not a valid zip file.")
159+
return False
160+
finally:
161+
if os.path.exists(zip_path):
162+
os.remove(zip_path)
163+
164+
return True
165+
166+
141167
if __name__ == '__main__':
142168
print("Fetching Forge versions...")
143169
forge_versions = get_forge_versions()

0 commit comments

Comments
 (0)