From 7c14c2463b8b432d4020e65b32ea301de8466878 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 15:17:18 +0000 Subject: [PATCH 01/70] Main upload with 1000 lines --- main.py | 554 +++++++++++++-- scripts/Launcher.hta | 33 + scripts/Launcher.ps1 | 125 ++++ scripts/Launcher.targets | 44 ++ scripts/Launcher.vbs | 178 +++++ scripts/PortableMCLoader.cs | 135 ++++ scripts/portablemc.py | 1317 +++++++++++++++++++++++++++++++++++ 7 files changed, 2319 insertions(+), 67 deletions(-) create mode 100644 scripts/Launcher.hta create mode 100644 scripts/Launcher.ps1 create mode 100644 scripts/Launcher.targets create mode 100644 scripts/Launcher.vbs create mode 100644 scripts/PortableMCLoader.cs create mode 100644 scripts/portablemc.py diff --git a/main.py b/main.py index c9bd6bc..9e1e0cb 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -""" +r""" Cross‑platform bootstrap script for embedded Python 3.14 (Windows only). -Attempts to download the native portablemc binary, falls back to pip. +All files are stored in %LOCALAPPDATA%\PortableMC. Run with any Python (e.g., system 3.11) to set up and launch the launcher. """ @@ -14,6 +14,7 @@ import shutil import platform import ssl +import winreg from pathlib import Path # --- Windows-only guard --- @@ -22,13 +23,33 @@ sys.exit(1) # --- Configuration --- -EMBEDDED_DIR = Path(__file__).parent / "python" +# Use AppData for all downloads and runtime files +APPDATA = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) +BASE_DIR = APPDATA / "PortableMC" +EMBEDDED_DIR = BASE_DIR / "python" EMBEDDED_PYTHON = EMBEDDED_DIR / "python.exe" PYTHON_VERSION = "3.14.3" PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] -PORTABLEMC_BIN_DIR = Path(__file__).parent / "portablemc_bin" +PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" + +# Default game settings +DEFAULT_USERNAME = "CubeUniform840" +DEFAULT_SERVER_IP = "77.103.184.72" +DEFAULT_JVM_OPTS = "-Xmx3G -Xms3G" + +# Paths +ROOT_DIR = Path(__file__).parent +SCRIPTS_DIR = ROOT_DIR / "Scripts" +SCRIPTS_DIR.mkdir(exist_ok=True) +MSBUILD_PATH = r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe" + +# Launcher files inside the Scripts folder +TARGETS_FILE = SCRIPTS_DIR / "Launcher.targets" +LAUNCHER_VBS = SCRIPTS_DIR / "Launcher.vbs" +LAUNCHER_PS1 = SCRIPTS_DIR / "Launcher.ps1" +PORTABLEMC_PY = SCRIPTS_DIR / "portablemc.py" # --- OS and architecture detection (for portablemc binary) --- SYSTEM = platform.system().lower() @@ -71,37 +92,123 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename +# --- Data functions --- +def prepare_user_data(): + """Move static folder and game files to BASE_DIR if not already present.""" + base_dir = BASE_DIR + root_dir = ROOT_DIR + + # Static folder + src_static = root_dir / "static" + dst_static = base_dir / "static" + if src_static.exists() and src_static.is_dir() and not dst_static.exists(): + try: + shutil.copytree(src_static, dst_static) + print("✅ Static folder moved to %LOCALAPPDATA%\\PortableMC\\static") + except Exception as e: + print(f"⚠️ Failed to copy static folder: {e}") + elif dst_static.exists(): + print("ℹ️ Static folder already exists in %LOCALAPPDATA%\\PortableMC") + + # Game files + for filename in ["servers.dat", "options.txt"]: + src = root_dir / filename + dst = base_dir / filename + if src.exists() and src.is_file() and not dst.exists(): + try: + shutil.copy2(src, dst) + print(f"✅ {filename} moved to %LOCALAPPDATA%\\PortableMC") + except Exception as e: + print(f"⚠️ Failed to copy {filename}: {e}") + elif dst.exists(): + print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + +# --- Junction functions --- +def create_junction(source, target): + """ + Create a junction from source to target. + Safely removes any existing target before creating the junction. + Returns True if a junction was created, False otherwise (fallback to regular directory). + """ + source_path = Path(source).resolve() + target_path = Path(target).resolve() + + # Ensure source exists (create if missing, so junction has a target) + source_path.mkdir(parents=True, exist_ok=True) + + # Remove existing target if it exists + if target_path.exists(): + if target_path.is_symlink(): + # This is a junction (or a symlink) – remove only the junction itself + try: + os.rmdir(str(target_path)) # works for junctions + print(f"Removed existing junction: {target_path}") + except Exception as e: + print(f"Warning: Could not remove junction {target_path}: {e}") + elif target_path.is_dir(): + # Regular directory – delete its contents but not the source folder + if target_path == source_path: + print(f"Target {target_path} is the same as source; skipping removal.") + else: + shutil.rmtree(str(target_path), ignore_errors=True) + print(f"Removed existing directory: {target_path}") + else: + target_path.unlink() + + # Try to create junction + try: + subprocess.run( + ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], + check=True, capture_output=True, text=True + ) + print(f"✅ Junction created: {target_path} -> {source_path}") + return True + except subprocess.CalledProcessError as e: + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + target_path.mkdir(parents=True, exist_ok=True) + return False + +def ensure_junctions(): + r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" + base_dir = BASE_DIR + base_dir.mkdir(parents=True, exist_ok=True) + + create_junction(ROOT_DIR / "mods", base_dir / "mods") + create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- Helper functions --- def ensure_embedded_python(): + """Download and extract embedded Python to BASE_DIR if missing.""" + BASE_DIR.mkdir(parents=True, exist_ok=True) if EMBEDDED_PYTHON.exists(): print("✅ Embedded Python already exists.") return True print("📥 Downloading embedded Python...") - # Try with verification first + zip_path = BASE_DIR / "python-embed.zip" try: - urllib.request.urlretrieve(PYTHON_URL, "python-embed.zip") + urllib.request.urlretrieve(PYTHON_URL, zip_path) except Exception as e: print(f"⚠️ First download attempt failed: {e}") print("📥 Retrying with SSL verification disabled (insecure)...") try: - # Create an unverified SSL context ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(PYTHON_URL, context=ssl_context) as response: - with open("python-embed.zip", 'wb') as out_file: + with open(zip_path, 'wb') as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download Python even with unverified SSL: {e2}") return False print("📦 Extracting...") - with zipfile.ZipFile("python-embed.zip", "r") as zip_ref: + with zipfile.ZipFile(zip_path, "r") as zip_ref: zip_ref.extractall(EMBEDDED_DIR) - os.remove("python-embed.zip") + zip_path.unlink() print("✅ Embedded Python ready.") return True def fix_pth_file(): + """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) if not pth_files: print("❌ No ._pth file found; cannot enable site-packages.") @@ -130,14 +237,16 @@ def test_embedded_python(): print(f"❌ Embedded Python test failed: {result.stderr}") return False except OSError as e: - # Common error: WinError 1260 (group policy block) print(f"❌ Cannot run embedded Python (blocked by group policy?): {e}") return False except Exception as e: print(f"❌ Embedded Python test error: {e}") return False -def download_get_pip(): +def download_get_pip(python_exe=None): + """Download get-pip.py into the embedded Python directory.""" + if python_exe is None: + python_exe = EMBEDDED_PYTHON pip_script = EMBEDDED_DIR / "get-pip.py" if pip_script.exists(): print("✅ get-pip.py already present.") @@ -145,7 +254,6 @@ def download_get_pip(): print("📥 Downloading get-pip.py (ignoring SSL cert for this request)...") try: - # Create an unverified SSL context to bypass certificate errors ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(GET_PIP_URL, context=ssl_context) as response: with open(pip_script, 'wb') as out_file: @@ -156,13 +264,15 @@ def download_get_pip(): return None return pip_script -def run_pip_command(args, isolated=True): +def run_pip_command(args, isolated=True, python_exe=None): + """Run a pip command with the given Python executable.""" + if python_exe is None: + python_exe = EMBEDDED_PYTHON env = os.environ.copy() if isolated: env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(EMBEDDED_PYTHON)] + args - # SAFE: args are static or come from trusted BASE_PACKAGES + cmd = [str(python_exe)] + args result = subprocess.run(cmd, env=env, capture_output=True, text=True) if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") @@ -171,14 +281,17 @@ def run_pip_command(args, isolated=True): print(result.stdout) return True -def install_pip(): - pip_script = download_get_pip() +def install_pip(python_exe=None): + """Install pip into the embedded Python environment.""" + if python_exe is None: + python_exe = EMBEDDED_PYTHON + pip_script = download_get_pip(python_exe) if not pip_script: return False env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(EMBEDDED_PYTHON), str(pip_script), + cmd = [str(python_exe), str(pip_script), "--trusted-host=files.pythonhosted.org", "--trusted-host=pypi.org"] result = subprocess.run(cmd, env=env, capture_output=True, text=True) @@ -189,24 +302,29 @@ def install_pip(): print("✅ pip installed.") return True -def install_base_packages(): +def install_base_packages(python_exe=None): + """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") - if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True): + if python_exe is None: + python_exe = EMBEDDED_PYTHON + if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command(["-m", "pip", "install", pkg], isolated=True): + if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): print(f"❌ Failed to install {pkg}.") return False return True -def get_certifi_path(): +def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" + if python_exe is None: + python_exe = EMBEDDED_PYTHON try: result = subprocess.run( - [str(EMBEDDED_PYTHON), "-c", "import certifi; print(certifi.where())"], + [str(python_exe), "-c", "import certifi; print(certifi.where())"], capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} # ensure isolation + env={"PYTHONNOUSERSITE": "1"} ) path = result.stdout.strip() if path and Path(path).exists(): @@ -216,13 +334,14 @@ def get_certifi_path(): return None def download_portablemc_binary(): + """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() if not url: return False print(f"📥 Downloading portablemc from {url}...") - archive_path = Path(__file__).parent / "portablemc_download" + BASE_DIR.mkdir(parents=True, exist_ok=True) + archive_path = BASE_DIR / "portablemc_download" try: - # Attempt with verification first urllib.request.urlretrieve(url, archive_path) except Exception as e: print(f"⚠️ First download attempt failed: {e}") @@ -249,7 +368,7 @@ def download_portablemc_binary(): return False finally: archive_path.unlink(missing_ok=True) - # Move all extracted files to the root of PORTABLEMC_BIN_DIR + # Flatten subdirectories for item in PORTABLEMC_BIN_DIR.iterdir(): if item.is_dir(): for sub in item.iterdir(): @@ -258,7 +377,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True -def test_portablemc(): +def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first exe_name = "portablemc.exe" if SYSTEM == "windows" else "portablemc" @@ -278,10 +397,12 @@ def test_portablemc(): print("⏱️ portablemc binary check timed out or not found, trying module.") # Try module + if python_exe is None: + python_exe = EMBEDDED_PYTHON env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(EMBEDDED_PYTHON), "-m", "portablemc", "--help"] + cmd = [str(python_exe), "-m", "portablemc", "--help"] try: result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) if result.returncode == 0: @@ -291,45 +412,133 @@ def test_portablemc(): print("⏱️ portablemc module check timed out, assuming not available.") return None -def ensure_portablemc(): +def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" - method = test_portablemc() + method = test_portablemc(python_exe) if method: return method if download_portablemc_binary(): - method = test_portablemc() + method = test_portablemc(python_exe) if method: return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True): - method = test_portablemc() + if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): + method = test_portablemc(python_exe) if method: return method return None -def launch_launcher(method): - launcher_script = Path(__file__).parent / "portablemc.py" +# --- System Python detection --- +def get_system_python(): + """Find a system Python 3.x executable, preferring 3.11 or higher. + Returns the path to a usable Python interpreter, or None. + """ + candidates = [] + + # Add the current interpreter (if it's not the embedded Python we're trying to set up) + current = Path(sys.executable) + if current.exists() and current != EMBEDDED_PYTHON: + try: + result = subprocess.run([str(current), "--version"], capture_output=True, text=True, timeout=2) + if result.returncode == 0 and "Python 3" in result.stdout: + candidates.append(current) + except: + pass + + # From registry (both HKCU and HKLM) + for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): + try: + key = winreg.OpenKey(hive, r"Software\Python\PythonCore") + except FileNotFoundError: + continue + i = 0 + while True: + try: + ver = winreg.EnumKey(key, i) + if ver.startswith("3."): + install_path = winreg.QueryValue(key, f"{ver}\\InstallPath") + candidates.append(Path(install_path) / "python.exe") + i += 1 + except WindowsError: + break + winreg.CloseKey(key) + + # Also check common locations (including all versions from 3.9 to 3.14) + versions = ["3.14", "3.13", "3.12", "3.11", "3.10", "3.9"] + common_paths = [] + for ver in versions: + # Standard install locations + common_paths.append(f"C:\\Python{ver.replace('.', '')}\\python.exe") + common_paths.append(f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe") + common_paths.append(f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe") + common_paths.append(f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe") + # Add Sysnative and System32 for potential 64-bit Python from WOW64 + common_paths.append(r"C:\Windows\Sysnative\python.exe") + common_paths.append(r"C:\Windows\System32\python.exe") + + for p in common_paths: + candidates.append(Path(p)) + + # Also search PATH + for dir in os.environ.get("PATH", "").split(os.pathsep): + candidates.append(Path(dir) / "python.exe") + candidates.append(Path(dir) / "python3.exe") + + # Remove duplicates and check existence and version + seen = set() + valid = [] + for p in candidates: + if p.exists() and p not in seen: + seen.add(p) + try: + result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) + if result.returncode == 0 and "Python 3" in result.stdout: + version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" + valid.append((version_str, p)) + except: + pass + + if not valid: + return None + + # Sort by version descending (higher version first) + valid.sort(key=lambda x: x[0], reverse=True) + best = valid[0][1] + print(f"Selected system Python: {best}") + return best + +# --- Launcher functions --- +def launch_launcher(method, python_exe=None): + """Launch portablemc.py with the given Python executable.""" + launcher_script = PORTABLEMC_PY if not launcher_script.exists(): print("❌ portablemc.py not found in the same directory.") return False + if python_exe is None: + python_exe = EMBEDDED_PYTHON + env = os.environ.copy() + # Ensure paths for binaries (portablemc binary may be in PORTABLEMC_BIN_DIR) paths = [str(EMBEDDED_DIR), str(EMBEDDED_DIR / "Scripts"), str(PORTABLEMC_BIN_DIR)] env["PATH"] = os.pathsep.join(paths) + os.pathsep + env.get("PATH", "") env["__compat_layer"] = "runasinvoker" - env["PYTHONHOME"] = str(EMBEDDED_DIR) + env["LAUNCHER_ROOT"] = str(ROOT_DIR) + # For embedded Python, set PYTHONHOME; for system Python, leave it unset + if python_exe == EMBEDDED_PYTHON: + env["PYTHONHOME"] = str(EMBEDDED_DIR) env["CLICOLOR_FORCE"] = "1" env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" env["PORTABLEMC_METHOD"] = method - cert_path = get_certifi_path() + cert_path = get_certifi_path(python_exe) if cert_path: env["SSL_CERT_FILE"] = cert_path env["REQUESTS_CA_BUNDLE"] = cert_path - cmd = [str(EMBEDDED_PYTHON), str(launcher_script)] + cmd = [str(python_exe), str(launcher_script)] print(f"🚀 Launching: {' '.join(cmd)}") try: subprocess.run(cmd, env=env, check=True) @@ -340,40 +549,251 @@ def launch_launcher(method): print("⏹️ Interrupted by user.") return True -def main(): - print("=== Embedded Python Bootstrap (Windows only) ===") +def run_web_launcher(): + """Attempt to use embedded Python; if blocked, fall back to system Python.""" + print("\n=== Bootstrapping environment for web launcher ===\n") + # Move static folders and game files before launch + prepare_user_data() + # Try embedded Python if not ensure_embedded_python(): - sys.exit(1) - if not fix_pth_file(): - sys.exit(1) - if not test_embedded_python(): - print("❌ Embedded Python cannot be executed. Please check group policy or run without embedded isolation.") - print(" You may try to run portablemc.py directly with your system Python after installing dependencies:") - print(" pip install flask flask-socketio psutil ansi2html portablemc") - sys.exit(1) + print("⚠️ Could not set up embedded Python. Will try system Python.") + else: + if not fix_pth_file(): + print("⚠️ Failed to fix .pth file for embedded Python.") + if test_embedded_python(): + print("✅ Embedded Python is usable.") + # Setup pip and packages with embedded Python + env_check = os.environ.copy() + env_check["PYTHONNOUSERSITE"] = "1" + env_check["PYTHONPATH"] = "" + pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, capture_output=True, text=True) + if pip_check.returncode != 0: + print("📦 pip not found, installing...") + if not install_pip(EMBEDDED_PYTHON): + return False + else: + print(f"✅ pip already installed: {pip_check.stdout.strip()}") + + if not install_base_packages(EMBEDDED_PYTHON): + return False + + method = ensure_portablemc(EMBEDDED_PYTHON) + if not method: + return False + + print("✅ Setup complete. Launching portablemc.py with embedded Python...") + return launch_launcher(method, EMBEDDED_PYTHON) + + # Embedded Python failed; fall back to system Python + print("\n⚠️ Embedded Python not usable. Trying system Python...") + sys_python = get_system_python() + if not sys_python: + print("❌ No system Python found. Cannot proceed.") + return False + + # Create a user-specific directory for Python packages (to avoid admin rights) + user_appdata = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + launcher_python_dir = user_appdata / "PythonLauncher" + launcher_python_dir.mkdir(exist_ok=True) + + # Set up environment to use this directory for site-packages + env = os.environ.copy() + env["PYTHONUSERBASE"] = str(launcher_python_dir) + env["PYTHONNOUSERSITE"] = "0" + env["PYTHONPATH"] = "" + # Install packages with system Python + print("📦 Installing required packages with system Python...") + for pkg in BASE_PACKAGES + ["portablemc"]: + print(f" Installing {pkg}...") + cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) + if result.returncode != 0: + print(f"❌ Failed to install {pkg}: {result.stderr}") + return False + print(f" ✅ {pkg} installed.") + + # Test portablemc with system Python + method = test_portablemc(sys_python) + if not method: + print("❌ portablemc not available even after installation.") + return False - # Check pip - env_check = os.environ.copy() - env_check["PYTHONNOUSERSITE"] = "1" - env_check["PYTHONPATH"] = "" - pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True) - if pip_check.returncode != 0: - print("📦 pip not found, installing...") - if not install_pip(): - sys.exit(1) + print("✅ Setup complete. Launching portablemc.py with system Python...") + # Launch portablemc.py with system Python, using the same environment + return launch_launcher(method, sys_python) + +def run_msbuild_launcher(): + print("\n=== Launching via MSBuild ===\n") + # Move static folders and game files before launch + prepare_user_data() + if not os.path.isfile(MSBUILD_PATH): + print(f"❌ MSBuild not found at {MSBUILD_PATH}") + return False + if not TARGETS_FILE.exists(): + print(f"❌ Launcher.targets not found at {TARGETS_FILE}") + return False + + env = os.environ.copy() + env["__COMPAT_LAYER"] = "RUNASINVOKER" + + cmd = [ + MSBUILD_PATH, + str(TARGETS_FILE), + f"/p:Username={DEFAULT_USERNAME}", + f"/p:ServerIp={DEFAULT_SERVER_IP}", + f'/p:JvmOpts="{DEFAULT_JVM_OPTS}"' + ] + print(f"Executing: {' '.join(cmd)}") + try: + subprocess.run(cmd, env=env, check=True) + except subprocess.CalledProcessError as e: + print(f"❌ MSBuild failed with exit code {e.returncode}") + return False + except KeyboardInterrupt: + print("⏹️ Interrupted by user.") + return True + +def run_cli_launcher(): + """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" + print("\n=== Bootstrapping environment for CLI launcher ===\n") + # Move static folders and game files before launch + prepare_user_data() + # Try embedded Python first + if not ensure_embedded_python(): + print("⚠️ Could not set up embedded Python. Will try system Python.") else: - print(f"✅ pip already installed: {pip_check.stdout.strip()}") + if not fix_pth_file(): + print("⚠️ Failed to fix .pth file for embedded Python.") + if test_embedded_python(): + # Setup embedded Python (pip + portablemc only) + env_check = os.environ.copy() + env_check["PYTHONNOUSERSITE"] = "1" + env_check["PYTHONPATH"] = "" + pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, capture_output=True, text=True) + if pip_check.returncode != 0: + print("📦 pip not found, installing...") + if not install_pip(EMBEDDED_PYTHON): + return False + else: + print(f"✅ pip already installed: {pip_check.stdout.strip()}") + + print("📦 Installing portablemc via pip...") + if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): + print("❌ Failed to install portablemc.") + return False + + method = test_portablemc(EMBEDDED_PYTHON) + if not method: + return False + + # Create Junctions for mods and resourcepacks + ensure_junctions() + + # Build CLI arguments (module syntax) + cmd = [ + str(EMBEDDED_PYTHON), "-m", "portablemc", + "--main-dir", ".", + "start", + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, + "fabric:", + "-u", DEFAULT_USERNAME + ] + env = os.environ.copy() + env["__COMPAT_LAYER"] = "RUNASINVOKER" + env["PYTHONNOUSERSITE"] = "1" + env["PYTHONPATH"] = "" + env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness + print(f"🚀 Launching: {' '.join(cmd)}") + try: + subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) + except subprocess.CalledProcessError as e: + print(f"❌ CLI launcher exited with error: {e}") + return False + except KeyboardInterrupt: + print("⏹️ Interrupted by user.") + return True - if not install_base_packages(): - sys.exit(1) + # Fallback to system Python + sys_python = get_system_python() + if not sys_python: + print("❌ No system Python found. Cannot proceed.") + return False - method = ensure_portablemc() + user_appdata = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + launcher_python_dir = user_appdata / "PythonLauncher" + launcher_python_dir.mkdir(exist_ok=True) + + env = os.environ.copy() + env["PYTHONUSERBASE"] = str(launcher_python_dir) + env["PYTHONNOUSERSITE"] = "0" + env["PYTHONPATH"] = "" + + # Install portablemc with system Python + print("📦 Installing portablemc with system Python...") + cmd = [str(sys_python), "-m", "pip", "install", "--user", "portablemc"] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) + if result.returncode != 0: + print(f"❌ Failed to install portablemc: {result.stderr}") + return False + + method = test_portablemc(sys_python) if not method: + print("❌ portablemc not available after installation.") + return False + + # Create Junctions for mods and resourcepacks + ensure_junctions() + + # Build CLI arguments (module syntax) + cmd = [ + str(sys_python), "-m", "portablemc", + "--main-dir", ".", + "start", + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, + "fabric:", + "-u", DEFAULT_USERNAME + ] + env["__COMPAT_LAYER"] = "RUNASINVOKER" + env["LAUNCHER_ROOT"] = str(ROOT_DIR) + print(f"🚀 Launching: {' '.join(cmd)}") + try: + subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) + except subprocess.CalledProcessError as e: + print(f"❌ CLI launcher exited with error: {e}") + return False + except KeyboardInterrupt: + print("⏹️ Interrupted by user.") + return True + +def main(): + # Display menu + print("\n" + "=" * 60) + print(" Minecraft Launcher – Choose a Method") + print("=" * 60) + print("1) Web launcher (portablemc.py) – requires setup, runs in browser") + print("2) MSBuild launcher – uses Launcher.targets (Microsoft‑signed binaries)") + print("3) CLI launcher – runs portablemc directly in terminal") + print("q) Quit") + choice = input("\nEnter choice (1/2/3/q): ").strip() + + if choice == "1": + success = run_web_launcher() + elif choice == "2": + success = run_msbuild_launcher() + elif choice == "3": + success = run_cli_launcher() + elif choice.lower() == "q": + print("Exiting.") + sys.exit(0) + else: + print("Invalid choice. Exiting.") sys.exit(1) - print("✅ Setup complete. Launching portablemc.py...") - launch_launcher(method) + sys.exit(0 if success else 1) if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/scripts/Launcher.hta b/scripts/Launcher.hta new file mode 100644 index 0000000..f6e0f0c --- /dev/null +++ b/scripts/Launcher.hta @@ -0,0 +1,33 @@ + + + +PortableMC Launcher Test + + + + + + \ No newline at end of file diff --git a/scripts/Launcher.ps1 b/scripts/Launcher.ps1 new file mode 100644 index 0000000..b57b72b --- /dev/null +++ b/scripts/Launcher.ps1 @@ -0,0 +1,125 @@ +$env:__COMPAT_LAYER = "RUNASINVOKER" + +param( + [string]$Username, + [string]$ServerIp, + [string]$JvmOpts +) + +# Fallback to positional arguments if needed +if (-not $JvmOpts -and $args.Count -ge 0) { + $JvmOpts = $args[0] +} + +Write-Host "DEBUG: Username = '$Username'" +Write-Host "DEBUG: ServerIp = '$ServerIp'" +Write-Host "DEBUG: JvmOpts = '$JvmOpts'" +if (-not $JvmOpts) { + Write-Host "ERROR: JvmOpts is empty. Cannot proceed." + exit 1 +} + +# Determine script location and base directories +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$rootDir = Split-Path -Parent $scriptDir +$baseDir = Join-Path $env:LOCALAPPDATA "PortableMC" +$binDir = Join-Path $baseDir "portablemc_bin" +$exePath = Join-Path $binDir "portablemc.exe" + +# --- Function to create a junction, removing any existing folder/link first --- +function Ensure-Junction { + param( + [string]$Source, + [string]$Target + ) + # Create source folder if it doesn't exist (so junction has a target) + if (-not (Test-Path $Source)) { + New-Item -ItemType Directory -Path $Source -Force | Out-Null + Write-Host "Created source folder: $Source" + } + # If target already exists, remove it (whether it's a folder or a junction) + if (Test-Path $Target) { + Remove-Item -Path $Target -Recurse -Force + Write-Host "Removed existing target: $Target" + } + Write-Host "Creating junction: $Target -> $Source" + cmd /c mklink /J "$Target" "$Source" 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Host "Junction created successfully." + } else { + Write-Host "Failed to create junction. Ensure source exists and you're on the same drive." + } +} + +# --- Ensure the base directory exists --- +if (-not (Test-Path $baseDir)) { + New-Item -ItemType Directory -Path $baseDir -Force | Out-Null +} + +# --- Download portablemc.exe if missing (with SSL fallback) --- +if (-not (Test-Path $exePath)) { + Write-Host "portablemc.exe not found. Downloading to $binDir..." + $url = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/portablemc-5.0.2-windows-x86_64-msvc.zip" + $zipPath = Join-Path $baseDir "portablemc.zip" + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing + } catch { + Write-Host "First download attempt failed: $_" + Write-Host "Retrying with SSL verification disabled..." + try { + [System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true} + Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing + } catch { + Write-Host "Download failed. Exiting." + exit 1 + } + } + Write-Host "Extracting..." + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $binDir) + Remove-Item $zipPath + + # Flatten subdirectories + Get-ChildItem $binDir -Directory | ForEach-Object { + Get-ChildItem $_.FullName -File | Move-Item -Destination $binDir -Force + Remove-Item $_.FullName -Recurse + } +} + +# --- Create junctions for mods and resourcepacks (force recreation) --- +Ensure-Junction -Source (Join-Path $rootDir "mods") -Target (Join-Path $baseDir "mods") +Ensure-Junction -Source (Join-Path $rootDir "resourcepacks") -Target (Join-Path $baseDir "resourcepacks") + +# --- Process JVM options and launch --- +$jvmArgs = $JvmOpts -replace ' ', ',' +$arguments = "--main-dir . start --join-server $ServerIp --jvm-arg=$jvmArgs fabric: -u $Username" + +Write-Host "Launching: $exePath $arguments" +Write-Host "Working directory: $baseDir" + +$env:__COMPAT_LAYER = "RUNASINVOKER" + +$tempOut = Join-Path $env:TEMP "portablemc_out.txt" +$tempErr = Join-Path $env:TEMP "portablemc_err.txt" +$process = Start-Process -FilePath $exePath -ArgumentList $arguments -WorkingDirectory $baseDir -NoNewWindow -PassThru -RedirectStandardOutput $tempOut -RedirectStandardError $tempErr + +$timeout = 30 +$elapsed = 0 +while ($elapsed -lt $timeout) { + if ($process.HasExited) { break } + Start-Sleep -Seconds 1 + $elapsed++ +} + +if ($process.HasExited) { + Write-Host "Process exited with code $($process.ExitCode)" + if (Test-Path $tempOut) { Get-Content $tempOut } + if (Test-Path $tempErr) { Get-Content $tempErr } + Remove-Item $tempOut, $tempErr -Force -ErrorAction SilentlyContinue + exit $process.ExitCode +} else { + Write-Host "Process still running after $timeout seconds detaching." + Remove-Item $tempOut, $tempErr -Force -ErrorAction SilentlyContinue + exit 0 +} \ No newline at end of file diff --git a/scripts/Launcher.targets b/scripts/Launcher.targets new file mode 100644 index 0000000..4b22e40 --- /dev/null +++ b/scripts/Launcher.targets @@ -0,0 +1,44 @@ + + + + $(TEMP) + $(TempDir)\PortableMCLoader.cs + $(TempDir)\PortableMCLoader.exe + $(MSBuildProjectDirectory)\PortableMCLoader.cs + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/Launcher.vbs b/scripts/Launcher.vbs new file mode 100644 index 0000000..d94a239 --- /dev/null +++ b/scripts/Launcher.vbs @@ -0,0 +1,178 @@ +' Launcher.vbs +' Usage: cscript Launcher.vbs +' Stores all files in %LOCALAPPDATA%\PortableMC and forces junction recreation for mods/resourcepacks. + +Set args = WScript.Arguments +If args.Count < 3 Then + WScript.Echo "Usage: cscript Launcher.vbs Username ServerIp JvmOpts" + WScript.Quit 1 +End If + +username = args(0) +serverIp = args(1) +jvmOpts = args(2) + +Set fso = CreateObject("Scripting.FileSystemObject") +Set shell = CreateObject("WScript.Shell") +shell.Environment("PROCESS")("__COMPAT_LAYER") = "RUNASINVOKER" + +' --- Get script directory --- +scriptDir = fso.GetParentFolderName(WScript.ScriptFullName) +' Root directory is one level up +rootDir = fso.GetParentFolderName(scriptDir) + +' --- Base directory in %LOCALAPPDATA% --- +localAppData = shell.ExpandEnvironmentStrings("%LOCALAPPDATA%") +baseDir = fso.BuildPath(localAppData, "PortableMC") +If Not fso.FolderExists(baseDir) Then fso.CreateFolder(baseDir) + +binDir = fso.BuildPath(baseDir, "portablemc_bin") +exePath = fso.BuildPath(binDir, "portablemc.exe") + +' --- Helper to create a junction, removing any existing folder/link first --- +Sub CreateJunction(source, target) + ' Ensure source folder exists (create if missing) + If Not fso.FolderExists(source) Then fso.CreateFolder(source) + ' If target already exists, delete it (whether folder or junction) + If fso.FolderExists(target) Then + fso.DeleteFolder target, True + WScript.Echo "Removed existing target: " & target + End If + WScript.Echo "Creating junction: " & target & " -> " & source + ' Use cmd /c mklink /J (target must not exist before) + cmd = "cmd /c mklink /J """ & target & """ """ & source & """" + shell.Run cmd, 0, True + ' Junction creation doesn't set exit code easily; assume success if target now exists + If fso.FolderExists(target) Then + WScript.Echo "Junction created successfully." + Else + WScript.Echo "Failed to create junction. Ensure source exists and you're on the same drive." + End If +End Sub + +' --- Download function with SSL fallback --- +Function DownloadFile(url, destPath) + On Error Resume Next + Set http = CreateObject("WinHttp.WinHttpRequest.5.1") + http.Open "GET", url, False + http.SetTimeouts 10000, 10000, 10000, 10000 + http.Send + If Err.Number = 0 And http.Status = 200 Then + Dim binaryData + binaryData = http.ResponseBody + Set stream = CreateObject("ADODB.Stream") + stream.Type = 1 + stream.Open + stream.Write binaryData + stream.SaveToFile destPath, 2 + stream.Close + DownloadFile = True + Exit Function + End If + ' Fallback to XMLHTTP with SSL ignore + Set xmlHttp = CreateObject("MSXML2.ServerXMLHTTP.6.0") + xmlHttp.setOption 2, 13056 + xmlHttp.Open "GET", url, False + xmlHttp.Send + If Err.Number = 0 And xmlHttp.Status = 200 Then + Set stream = CreateObject("ADODB.Stream") + stream.Type = 1 + stream.Open + stream.Write xmlHttp.ResponseBody + stream.SaveToFile destPath, 2 + stream.Close + DownloadFile = True + Else + WScript.Echo "Download failed: " & Err.Description + DownloadFile = False + End If + On Error Goto 0 +End Function + +Sub ExtractZip(zipPath, destDir) + If Not fso.FolderExists(destDir) Then fso.CreateFolder(destDir) + Set zip = CreateObject("Shell.Application").NameSpace(zipPath) + Set dest = CreateObject("Shell.Application").NameSpace(destDir) + dest.CopyHere zip.Items, 284 + WScript.Sleep 5000 +End Sub + +' --- Main download and extraction --- +If Not fso.FileExists(exePath) Then + WScript.Echo "portablemc.exe not found. Downloading..." + url = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/portablemc-5.0.2-windows-x86_64-msvc.zip" + zipPath = fso.BuildPath(baseDir, "portablemc.zip") + If Not DownloadFile(url, zipPath) Then + WScript.Echo "Download failed. Exiting." + WScript.Quit 1 + End If + Set file = fso.GetFile(zipPath) + WScript.Echo "Download complete. Size: " & file.Size & " bytes" + WScript.Echo "Extracting to: " & binDir + ExtractZip zipPath, binDir + fso.DeleteFile zipPath, True + + ' Flatten subdirectories + If fso.FolderExists(binDir) Then + Set folder = fso.GetFolder(binDir) + For Each subFolder In folder.SubFolders + For Each file In subFolder.Files + destFile = fso.BuildPath(binDir, file.Name) + If fso.FileExists(destFile) Then fso.DeleteFile destFile, True + file.Move destFile + Next + subFolder.Delete True + Next + End If +End If + +If Not fso.FileExists(exePath) Then + WScript.Echo "ERROR: portablemc.exe still not found at " & exePath + WScript.Quit 1 +End If + +' --- Create junctions for mods and resourcepacks (force recreation) --- +CreateJunction fso.BuildPath(rootDir, "mods"), fso.BuildPath(baseDir, "mods") +CreateJunction fso.BuildPath(rootDir, "resourcepacks"), fso.BuildPath(baseDir, "resourcepacks") + +' --- Build command line --- +jvmArgs = Replace(jvmOpts, " ", ",") +arguments = "--main-dir . start --join-server " & serverIp & " --jvm-arg=" & jvmArgs & " fabric: -u " & username + +WScript.Echo "Launching: " & exePath & " " & arguments +WScript.Echo "Working directory: " & baseDir + +' Set environment variable to run as invoker +shell.Environment("PROCESS")("__COMPAT_LAYER") = "RUNASINVOKER" + +' Change to base directory and launch +shell.CurrentDirectory = baseDir +Set proc = shell.Exec("""" & exePath & """ " & arguments) + +' Wait up to 30 seconds +Dim startTime, line +startTime = Timer +Do While Timer - startTime < 30 + While Not proc.StdOut.AtEndOfStream + WScript.StdOut.WriteLine proc.StdOut.ReadLine + Wend + While Not proc.StdErr.AtEndOfStream + WScript.StdErr.WriteLine proc.StdErr.ReadLine + Wend + If proc.Status <> 0 Then Exit Do + WScript.Sleep 200 +Loop + +If proc.Status = 0 Then + WScript.Echo "Process still running – detaching." + WScript.Quit 0 +Else + While Not proc.StdOut.AtEndOfStream + WScript.StdOut.WriteLine proc.StdOut.ReadLine + Wend + While Not proc.StdErr.AtEndOfStream + WScript.StdErr.WriteLine proc.StdErr.ReadLine + Wend + WScript.Echo "Process exited with code " & proc.ExitCode + WScript.Quit proc.ExitCode +End If \ No newline at end of file diff --git a/scripts/PortableMCLoader.cs b/scripts/PortableMCLoader.cs new file mode 100644 index 0000000..0806e5c --- /dev/null +++ b/scripts/PortableMCLoader.cs @@ -0,0 +1,135 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Diagnostics; + +class Program +{ + static void Main(string[] args) + { + if (args.Length < 3) + { + Console.WriteLine("Usage: PortableMCLoader.exe Username ServerIp JvmOpts"); + Environment.Exit(1); + } + string username = args[0]; + string serverIp = args[1]; + string jvmOpts = args[2]; + + string baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PortableMC"); + Directory.CreateDirectory(baseDir); + string binDir = Path.Combine(baseDir, "portablemc_bin"); + string exePath = Path.Combine(binDir, "portablemc.exe"); + + if (!File.Exists(exePath)) + { + Console.WriteLine("portablemc.exe not found. Downloading..."); + string url = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/portablemc-5.0.2-windows-x86_64-msvc.zip"; + string zipPath = Path.Combine(baseDir, "portablemc.zip"); + try + { + using (WebClient client = new WebClient()) + { + client.DownloadFile(url, zipPath); + } + } + catch (Exception ex) + { + Console.WriteLine("Download failed: " + ex.Message); + Console.WriteLine("Retrying with SSL disabled..."); + try + { + ServicePointManager.ServerCertificateValidationCallback = (sender, cert, chain, errors) => true; + using (WebClient client = new WebClient()) + { + client.DownloadFile(url, zipPath); + } + } + catch (Exception ex2) + { + Console.WriteLine("Download failed even with SSL disabled: " + ex2.Message); + Environment.Exit(1); + } + } + + Console.WriteLine("Extracting..."); + ZipFile.ExtractToDirectory(zipPath, binDir); + File.Delete(zipPath); + foreach (string dir in Directory.GetDirectories(binDir)) + { + foreach (string file in Directory.GetFiles(dir)) + { + string dest = Path.Combine(binDir, Path.GetFileName(file)); + if (File.Exists(dest)) File.Delete(dest); + File.Move(file, dest); + } + Directory.Delete(dir, true); + } + Console.WriteLine("Extraction complete."); + } + + string scriptDir = Directory.GetCurrentDirectory(); + CreateJunction(Path.Combine(scriptDir, "mods"), Path.Combine(baseDir, "mods")); + CreateJunction(Path.Combine(scriptDir, "resourcepacks"), Path.Combine(baseDir, "resourcepacks")); + + string jvmArgs = jvmOpts.Replace(' ', ','); + string arguments = string.Format("--main-dir . start --join-server {0} --jvm-arg={1} fabric: -u {2}", + serverIp, jvmArgs, username); + + Console.WriteLine(string.Format("Launching: {0} {1}", exePath, arguments)); + Console.WriteLine(string.Format("Working directory: {0}", baseDir)); + + Environment.SetEnvironmentVariable("__COMPAT_LAYER", "RUNASINVOKER"); + + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = exePath, + Arguments = arguments, + WorkingDirectory = baseDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using (Process proc = Process.Start(psi)) + { + proc.OutputDataReceived += (s, e) => { if (e.Data != null) Console.WriteLine(e.Data); }; + proc.ErrorDataReceived += (s, e) => { if (e.Data != null) Console.Error.WriteLine(e.Data); }; + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + + if (proc.WaitForExit(30000)) + { + Console.WriteLine(string.Format("Process exited with code {0}", proc.ExitCode)); + Environment.Exit(proc.ExitCode); + } + else + { + Console.WriteLine("Process still running – detaching."); + Environment.Exit(0); + } + } + } + + static void CreateJunction(string source, string target) + { + if (!Directory.Exists(source)) + Directory.CreateDirectory(source); + + if (Directory.Exists(target)) + { + Directory.Delete(target, true); + Console.WriteLine(string.Format("Removed existing target: {0}", target)); + } + + Console.WriteLine(string.Format("Creating junction: {0} -> {1}", target, source)); + Process.Start("cmd", string.Format("/c mklink /J \"{0}\" \"{1}\"", target, source)).WaitForExit(); + + if (Directory.Exists(target)) + Console.WriteLine("Junction created successfully."); + else + Console.WriteLine("Failed to create junction."); + } +} \ No newline at end of file diff --git a/scripts/portablemc.py b/scripts/portablemc.py new file mode 100644 index 0000000..4b73d2e --- /dev/null +++ b/scripts/portablemc.py @@ -0,0 +1,1317 @@ +from pathlib import Path +from flask import Flask, request, render_template_string, jsonify, Response +from flask_socketio import SocketIO, emit +import subprocess +import sys +import socket +import time +import re +import signal +import shutil +import threading +import logging +import os +import json +import importlib.util + +# Determine base directory (where Minecraft files are stored) +APPDATA = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) +BASE_DIR = APPDATA / "PortableMC" +BASE_DIR.mkdir(parents=True, exist_ok=True) + +# Flask app with static folder in BASE_DIR +app = Flask(__name__, static_folder=str(BASE_DIR / "static")) +socketio = SocketIO(app, cors_allowed_origins="*") + +def escape_html(s): + """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" + return s.replace('&', '&').replace('<', '<').replace('>', '>') + +# --- CONFIGURATION --- +VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] +PASS_KEY = "1234" +SERVER_IP = "77.103.184.72" +# SERVER_IP = "eu.chickencraft.nl" +JVM_OPTS = "-Xmx3G -Xms3G -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1NewSizePercent=20 -XX:G1ReservePercent=20 -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=32M -XX:+AlwaysPreTouch -XX:+ParallelRefProcEnabled -XX:+DisableExplicitGC" + +# Thread-safe process tracking +active_processes = {} +processes_lock = threading.Lock() + +# Track connected clients +connected_clients = 0 +clients_lock = threading.Lock() +logging.basicConfig(level=logging.INFO) + +# Path of root of Launcher +LAUNCHER_ROOT = os.environ.get("LAUNCHER_ROOT") +if LAUNCHER_ROOT is None: + LAUNCHER_ROOT = str(Path(__file__).parent.parent) +ROOT_DIR = Path(LAUNCHER_ROOT) + +# Optional psutil for process tree killing +try: + import psutil + PSUTIL_AVAILABLE = True +except ImportError: + psutil = None + PSUTIL_AVAILABLE = False + logging.warning( + "psutil not installed; process tree killing is limited. " + "Install psutil for full cleanup." + ) + +# Fallbacks when certain libraries are restricted/corrupted/disabled +try: + from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) + use_server_conversion = True +except ImportError: + # ansi2html not installed – fallback to raw ANSI + use_server_conversion = False + logging.warning("ansi2html not installed; client will handle ANSI conversion.") + +# --- HTML TEMPLATE (with 4-space indented JavaScript) --- +HTML_TEMPLATE = r""" + + + + + Minecraft - Java Edition + + + +
+ +
+ + Ambient Mode +
+ +
+ +
+
+
+
Minecraft Java 1.21.11
+
+
+ Flask: Connected +
+ {% if error %}
{{ error }}
{% endif %} + +
+
+ + +
+ + + + +
+
+ +
+
SYSTEM_CORE // LIVE_LOGS
+
+
+
+ +
PORTABLE_MC // APEX_V6.5
+ + + + + + + + + + +""" + +# --- JUNCTIONS --- +def create_junction(source, target): + """ + Create a junction from source to target. + Safely removes any existing target before creating the junction. + Returns True if a junction was created, False otherwise (fallback to regular directory). + """ + source_path = Path(source).resolve() + target_path = Path(target).resolve() + + # Ensure source exists (create if missing, so junction has a target) + source_path.mkdir(parents=True, exist_ok=True) + + # Remove existing target if it exists + if target_path.exists(): + if target_path.is_symlink(): + # This is a junction (or a symlink) – remove only the junction itself + try: + os.rmdir(str(target_path)) # works for junctions + print(f"Removed existing junction: {target_path}") + except Exception as e: + print(f"Warning: Could not remove junction {target_path}: {e}") + elif target_path.is_dir(): + # Regular directory – delete its contents but not the source folder + if target_path == source_path: + print(f"Target {target_path} is the same as source; skipping removal.") + else: + shutil.rmtree(str(target_path), ignore_errors=True) + print(f"Removed existing directory: {target_path}") + else: + target_path.unlink() + + # Try to create junction + try: + subprocess.run( + ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], + check=True, capture_output=True, text=True + ) + print(f"✅ Junction created: {target_path} -> {source_path}") + return True + except subprocess.CalledProcessError as e: + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + target_path.mkdir(parents=True, exist_ok=True) + return False + +def ensure_junctions(): + r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" + base_dir = BASE_DIR + base_dir.mkdir(parents=True, exist_ok=True) + + create_junction(ROOT_DIR / "mods", base_dir / "mods") + create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + +# --- ROUTES --- +@socketio.on('connect') +def handle_connect(): + global connected_clients + with clients_lock: + connected_clients += 1 + client_id = request.sid + print(f'Client connected: {client_id} (Total: {connected_clients})') + emit('status', {'core': 'online', 'minecraft': 'checking'}) + +@socketio.on('disconnect') +def handle_disconnect(): + global connected_clients + with clients_lock: + connected_clients -= 1 + client_id = request.sid + print(f'Client disconnected: {client_id} (Total: {connected_clients})') + +@socketio.on('ping_minecraft') +def handle_ping_minecraft(): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(1.5) + s.connect((SERVER_IP, 25565)) + s.close() + emit('minecraft_status', {'online': True}) + except Exception: + emit('minecraft_status', {'online': False}) + +@app.route("/ping") +def ping(): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(1.5) + s.connect((SERVER_IP, 25565)) + s.close() + return jsonify(online=True) + except Exception: + return jsonify(online=False), 200 + +@app.route("/stream") +def stream(): + # --- Create junctions for mods and resourcepacks (like .vbs/.ps1) --- + ensure_junctions() # This will create junctions in %LOCALAPPDATA%\PortableMC + + # --- PORTABLEMC AVAILABILITY CHECK --- + def is_portablemc_available(): + if shutil.which("portablemc"): + return True + return importlib.util.find_spec("portablemc") is not None + + if not is_portablemc_available(): + def error_gen(): + lines = [ + "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + ] + for line in lines: + payload = json.dumps({'type': 'ansi', 'content': line}) + yield f"data: {payload}\n\n" + yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") + + # --- GET USER INPUT --- + user = request.args.get("username", "Player").strip() + password = request.args.get("password", "") + + # --- VALIDATIONS (JSON wrapped) --- + if not user: + def error_gen(): + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + yield f"data: {payload}\n\n" + yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") + + if not VALID_USERNAME_REGEX.match(user): + def error_gen(): + lines = [ + "\x1b[91m[!] INVALID USERNAME\x1b[0m", + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + ] + for line in lines: + payload = json.dumps({'type': 'ansi', 'content': line}) + yield f"data: {payload}\n\n" + yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") + + user_lower = user.lower() + forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] + if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + yield f"data: {payload}\n\n" + yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") + + # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- + with processes_lock: + if user in active_processes and active_processes[user].poll() is None: + lines = [ + "\x1b[91m[!] CORE BUSY\x1b[0m", + "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", + "\x1b[90mPlease close the game before launching again.\x1b[0m" + ] + def error_gen(): + for line in lines: + payload = json.dumps({'type': 'ansi', 'content': line}) + yield f"data: {payload}\n\n" + yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") + if user in active_processes: + del active_processes[user] + + # --- BUILD COMMAND (with binary vs module adaptation) --- + using_binary = os.environ.get("PORTABLEMC_METHOD") == "binary" + + # Launcher command – tied directly to the method, not PATH + if using_binary: + launcher_cmd = ["portablemc"] + else: + launcher_cmd = [sys.executable, "-m", "portablemc"] + + # Determine base directory for portablemc data (same as the launcher uses) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + base_dir = os.path.join(local_appdata, "PortableMC") + + # Global arguments (before 'start') + global_args = ["--main-dir", base_dir] + if not using_binary: + # Python module supports these extras + global_args.extend(["--timeout", "60", "--output", "human-color"]) + + # Start‑specific arguments + start_args = [] + if using_binary: + # Binary uses --join-server + start_args.extend(["--join-server", SERVER_IP]) + else: + # Python module uses --server + start_args.extend(["--server", SERVER_IP]) + + # JVM arguments + if using_binary: + # Binary expects --jvm-arg=OPT1,OPT2,OPT3 + jvm_opts_combined = ",".join(JVM_OPTS.split()) + start_args.append(f"--jvm-arg={jvm_opts_combined}") + else: + # Python module accepts a single --jvm-args string + start_args.extend(["--jvm-args", JVM_OPTS]) + + # Version (fabric:) + start_args.append("fabric:") + + # Username + start_args.extend(["-u", user]) + + # Custom Java path (supported by both) + java_exe = "java.exe" if os.name == "nt" else "java" + java_bin = Path.cwd() / "jvm" / "java-runtime-delta" / "bin" / java_exe + if java_bin.exists(): + # Insert --jvm and its path right after the server arguments + # Current start_args: [server args, jvm args, version, -u user] + # We want to insert before the version, so after the server args. + # Server args are two elements, so index 2. + start_args[2:2] = ["--jvm", str(java_bin)] + + # Full command + cmd = launcher_cmd + global_args + ["start"] + start_args + + # --- DISCONNECT DETECTION --- + closed_event = threading.Event() + progress_re = re.compile(r"(\d+/\d+)") + + def generate(): + process = None + got_generator_exit = False + try: + with processes_lock: + if user in active_processes and active_processes[user].poll() is None: + busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + yield f"data: {busy_payload}\n\n" + yield "data: CLOSE\n\n" + return + + startupinfo = None + if os.name == 'nt': + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + + # SAFE: shell=False, all arguments are separate list elements. + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + shell=False, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + startupinfo=startupinfo + ) + active_processes[user] = process + logging.info(f"Started process for {user} with PID {process.pid}") + + last_progress = "" + last_send_time = time.perf_counter() + update_interval = 0.15 + ok_reached = False + + while True: + if closed_event.is_set(): + disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + try: + yield f"data: {disc_msg}\n\n" + except (BrokenPipeError, OSError, GeneratorExit): + pass + break + + line = process.stdout.readline() + if not line: + if process.poll() is not None: + break + continue + + raw_line = line.rstrip('\n') + now = time.perf_counter() + + if not ok_reached and "[ OK ]" in raw_line: + ok_reached = True + + progress_match = progress_re.search(raw_line) + + if use_server_conversion and ansi_converter: + try: + safe_line = escape_html(raw_line) + html_line = ansi_converter.convert(safe_line, full=False).strip() + payload = json.dumps({'type': 'html', 'content': html_line}) + except Exception as e: + logging.error(f"Conversion failed: {e}, sending raw ANSI") + payload = json.dumps({'type': 'ansi', 'content': raw_line}) + else: + payload = json.dumps({'type': 'ansi', 'content': raw_line}) + + try: + if progress_match and not ok_reached: + current_file = progress_match.group(1) + if current_file != last_progress and (now - last_send_time) > update_interval: + yield f"data: {payload}\n\n" + last_progress = current_file + last_send_time = now + else: + yield f"data: {payload}\n\n" + except (BrokenPipeError, OSError, GeneratorExit) as e: + if isinstance(e, GeneratorExit): + got_generator_exit = True + break + + except FileNotFoundError as e: + if not closed_event.is_set(): + try: + payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + yield f"data: {payload}\n\n" + except Exception: + pass + except Exception as e: + if not closed_event.is_set(): + try: + payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + yield f"data: {payload}\n\n" + except Exception: + pass + finally: + if process: + process.stdout.close() + kill_process_tree(process) + with processes_lock: + if user in active_processes: + del active_processes[user] + logging.info(f"Removed process entry for {user}") + + if not got_generator_exit: + try: + ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) + tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + yield f"data: {ended_msg}\n\n" + yield f"data: {tip_msg}\n\n" + yield "data: CLOSE\n\n" + except GeneratorExit: + pass + except Exception: + pass + + response = Response(generate(), mimetype="text/event-stream") + response.call_on_close(closed_event.set) + return response + +@app.route("/") +def home(): + return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + +def kill_process_tree(proc): + """Kill a process and all its children. Falls back to simple kill if psutil missing.""" + if proc.poll() is not None: + logging.debug(f"Process {proc.pid} already dead") + return + if PSUTIL_AVAILABLE: + try: + parent = psutil.Process(proc.pid) + children = parent.children(recursive=True) + for child in children: + try: + child.kill() + except psutil.NoSuchProcess: + pass + parent.kill() + logging.info(f"Killed process tree for PID {proc.pid}") + except psutil.NoSuchProcess: + logging.debug(f"Process {proc.pid} already gone") + else: + try: + proc.terminate() + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + +def graceful_shutdown(sig, frame): + logging.info("SHUTTING DOWN CORE...") + with processes_lock: + for user, proc in list(active_processes.items()): + kill_process_tree(proc) + sys.exit(0) + +signal.signal(signal.SIGINT, graceful_shutdown) + +if __name__ == "__main__": + try: + socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) + except KeyboardInterrupt: + graceful_shutdown(None, None) \ No newline at end of file From b212f384473ec94c7bfbea3c7ad01bac6c69db3b Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 15:18:12 +0000 Subject: [PATCH 02/70] Update and rename portablemc.py to scrtips/portablemc.py --- portablemc.py => scrtips/portablemc.py | 77 +++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 3 deletions(-) rename portablemc.py => scrtips/portablemc.py (91%) diff --git a/portablemc.py b/scrtips/portablemc.py similarity index 91% rename from portablemc.py rename to scrtips/portablemc.py index 035b83c..645fc93 100644 --- a/portablemc.py +++ b/scrtips/portablemc.py @@ -10,12 +10,17 @@ import shutil import threading import logging -import traceback import os import json import importlib.util -app = Flask(__name__) +# Determine base directory (where Minecraft files are stored) +APPDATA = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) +BASE_DIR = APPDATA / "PortableMC" +BASE_DIR.mkdir(parents=True, exist_ok=True) + +# Flask app with static folder in BASE_DIR +app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") def escape_html(s): @@ -39,6 +44,12 @@ def escape_html(s): clients_lock = threading.Lock() logging.basicConfig(level=logging.INFO) +# Path of root of Launcher +LAUNCHER_ROOT = os.environ.get("LAUNCHER_ROOT") +if LAUNCHER_ROOT is None: + LAUNCHER_ROOT = str(Path(__file__).parent.parent) +ROOT_DIR = Path(LAUNCHER_ROOT) + # Optional psutil for process tree killing try: import psutil @@ -909,6 +920,59 @@ def escape_html(s): """ +# --- JUNCTIONS --- +def create_junction(source, target): + """ + Create a junction from source to target. + Safely removes any existing target before creating the junction. + Returns True if a junction was created, False otherwise (fallback to regular directory). + """ + source_path = Path(source).resolve() + target_path = Path(target).resolve() + + # Ensure source exists (create if missing, so junction has a target) + source_path.mkdir(parents=True, exist_ok=True) + + # Remove existing target if it exists + if target_path.exists(): + if target_path.is_symlink(): + # This is a junction (or a symlink) – remove only the junction itself + try: + os.rmdir(str(target_path)) # works for junctions + print(f"Removed existing junction: {target_path}") + except Exception as e: + print(f"Warning: Could not remove junction {target_path}: {e}") + elif target_path.is_dir(): + # Regular directory – delete its contents but not the source folder + if target_path == source_path: + print(f"Target {target_path} is the same as source; skipping removal.") + else: + shutil.rmtree(str(target_path), ignore_errors=True) + print(f"Removed existing directory: {target_path}") + else: + target_path.unlink() + + # Try to create junction + try: + subprocess.run( + ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], + check=True, capture_output=True, text=True + ) + print(f"✅ Junction created: {target_path} -> {source_path}") + return True + except subprocess.CalledProcessError as e: + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + target_path.mkdir(parents=True, exist_ok=True) + return False + +def ensure_junctions(): + r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" + base_dir = BASE_DIR + base_dir.mkdir(parents=True, exist_ok=True) + + create_junction(ROOT_DIR / "mods", base_dir / "mods") + create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- ROUTES --- @socketio.on('connect') def handle_connect(): @@ -951,6 +1015,9 @@ def ping(): @app.route("/stream") def stream(): + # --- Create junctions for mods and resourcepacks (like .vbs/.ps1) --- + ensure_junctions() # This will create junctions in %LOCALAPPDATA%\PortableMC + # --- PORTABLEMC AVAILABILITY CHECK --- def is_portablemc_available(): if shutil.which("portablemc"): @@ -1028,8 +1095,12 @@ def error_gen(): else: launcher_cmd = [sys.executable, "-m", "portablemc"] + # Determine base directory for portablemc data (same as the launcher uses) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + base_dir = os.path.join(local_appdata, "PortableMC") + # Global arguments (before 'start') - global_args = ["--main-dir", "."] + global_args = ["--main-dir", base_dir] if not using_binary: # Python module supports these extras global_args.extend(["--timeout", "60", "--output", "human-color"]) From c9963948386fa439d205390f0a72471b98c58b3f Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 15:19:34 +0000 Subject: [PATCH 03/70] Remove old portablemc.py --- scrtips/portablemc.py | 1317 ----------------------------------------- 1 file changed, 1317 deletions(-) delete mode 100644 scrtips/portablemc.py diff --git a/scrtips/portablemc.py b/scrtips/portablemc.py deleted file mode 100644 index 645fc93..0000000 --- a/scrtips/portablemc.py +++ /dev/null @@ -1,1317 +0,0 @@ -from pathlib import Path -from flask import Flask, request, render_template_string, jsonify, Response -from flask_socketio import SocketIO, emit -import subprocess -import sys -import socket -import time -import re -import signal -import shutil -import threading -import logging -import os -import json -import importlib.util - -# Determine base directory (where Minecraft files are stored) -APPDATA = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) -BASE_DIR = APPDATA / "PortableMC" -BASE_DIR.mkdir(parents=True, exist_ok=True) - -# Flask app with static folder in BASE_DIR -app = Flask(__name__, static_folder=str(BASE_DIR / "static")) -socketio = SocketIO(app, cors_allowed_origins="*") - -def escape_html(s): - """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') - -# --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') -FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] -PASS_KEY = "1234" -SERVER_IP = "77.103.184.72" -# SERVER_IP = "eu.chickencraft.nl" -JVM_OPTS = "-Xmx3G -Xms3G -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1NewSizePercent=20 -XX:G1ReservePercent=20 -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=32M -XX:+AlwaysPreTouch -XX:+ParallelRefProcEnabled -XX:+DisableExplicitGC" - -# Thread-safe process tracking -active_processes = {} -processes_lock = threading.Lock() - -# Track connected clients -connected_clients = 0 -clients_lock = threading.Lock() -logging.basicConfig(level=logging.INFO) - -# Path of root of Launcher -LAUNCHER_ROOT = os.environ.get("LAUNCHER_ROOT") -if LAUNCHER_ROOT is None: - LAUNCHER_ROOT = str(Path(__file__).parent.parent) -ROOT_DIR = Path(LAUNCHER_ROOT) - -# Optional psutil for process tree killing -try: - import psutil - PSUTIL_AVAILABLE = True -except ImportError: - psutil = None - PSUTIL_AVAILABLE = False - logging.warning( - "psutil not installed; process tree killing is limited. " - "Install psutil for full cleanup." - ) - -# Fallbacks when certain libraries are restricted/corrupted/disabled -try: - from ansi2html import Ansi2HTMLConverter - ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) - use_server_conversion = True -except ImportError: - # ansi2html not installed – fallback to raw ANSI - use_server_conversion = False - logging.warning("ansi2html not installed; client will handle ANSI conversion.") - -# --- HTML TEMPLATE (with 4-space indented JavaScript) --- -HTML_TEMPLATE = r""" - - - - - Minecraft - Java Edition - - - -
- -
- - Ambient Mode -
- -
- -
-
-
-
Minecraft Java 1.21.11
-
-
- Flask: Connected -
- {% if error %}
{{ error }}
{% endif %} - -
-
- - -
- - - - -
-
- -
-
SYSTEM_CORE // LIVE_LOGS
-
-
-
- -
PORTABLE_MC // APEX_V6.5
- - - - - - - - - - -""" - -# --- JUNCTIONS --- -def create_junction(source, target): - """ - Create a junction from source to target. - Safely removes any existing target before creating the junction. - Returns True if a junction was created, False otherwise (fallback to regular directory). - """ - source_path = Path(source).resolve() - target_path = Path(target).resolve() - - # Ensure source exists (create if missing, so junction has a target) - source_path.mkdir(parents=True, exist_ok=True) - - # Remove existing target if it exists - if target_path.exists(): - if target_path.is_symlink(): - # This is a junction (or a symlink) – remove only the junction itself - try: - os.rmdir(str(target_path)) # works for junctions - print(f"Removed existing junction: {target_path}") - except Exception as e: - print(f"Warning: Could not remove junction {target_path}: {e}") - elif target_path.is_dir(): - # Regular directory – delete its contents but not the source folder - if target_path == source_path: - print(f"Target {target_path} is the same as source; skipping removal.") - else: - shutil.rmtree(str(target_path), ignore_errors=True) - print(f"Removed existing directory: {target_path}") - else: - target_path.unlink() - - # Try to create junction - try: - subprocess.run( - ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True - ) - print(f"✅ Junction created: {target_path} -> {source_path}") - return True - except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") - target_path.mkdir(parents=True, exist_ok=True) - return False - -def ensure_junctions(): - r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" - base_dir = BASE_DIR - base_dir.mkdir(parents=True, exist_ok=True) - - create_junction(ROOT_DIR / "mods", base_dir / "mods") - create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") - -# --- ROUTES --- -@socketio.on('connect') -def handle_connect(): - global connected_clients - with clients_lock: - connected_clients += 1 - client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) - -@socketio.on('disconnect') -def handle_disconnect(): - global connected_clients - with clients_lock: - connected_clients -= 1 - client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') - -@socketio.on('ping_minecraft') -def handle_ping_minecraft(): - try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(1.5) - s.connect((SERVER_IP, 25565)) - s.close() - emit('minecraft_status', {'online': True}) - except Exception: - emit('minecraft_status', {'online': False}) - -@app.route("/ping") -def ping(): - try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(1.5) - s.connect((SERVER_IP, 25565)) - s.close() - return jsonify(online=True) - except Exception: - return jsonify(online=False), 200 - -@app.route("/stream") -def stream(): - # --- Create junctions for mods and resourcepacks (like .vbs/.ps1) --- - ensure_junctions() # This will create junctions in %LOCALAPPDATA%\PortableMC - - # --- PORTABLEMC AVAILABILITY CHECK --- - def is_portablemc_available(): - if shutil.which("portablemc"): - return True - return importlib.util.find_spec("portablemc") is not None - - if not is_portablemc_available(): - def error_gen(): - lines = [ - "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" - ] - for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) - yield f"data: {payload}\n\n" - yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") - - # --- GET USER INPUT --- - user = request.args.get("username", "Player").strip() - password = request.args.get("password", "") - - # --- VALIDATIONS (JSON wrapped) --- - if not user: - def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) - yield f"data: {payload}\n\n" - yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") - - if not VALID_USERNAME_REGEX.match(user): - def error_gen(): - lines = [ - "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" - ] - for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) - yield f"data: {payload}\n\n" - yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") - - user_lower = user.lower() - forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] - if user_lower in forbidden_lower and password != PASS_KEY: - def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) - yield f"data: {payload}\n\n" - yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") - - # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- - with processes_lock: - if user in active_processes and active_processes[user].poll() is None: - lines = [ - "\x1b[91m[!] CORE BUSY\x1b[0m", - "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" - ] - def error_gen(): - for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) - yield f"data: {payload}\n\n" - yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") - if user in active_processes: - del active_processes[user] - - # --- BUILD COMMAND (with binary vs module adaptation) --- - using_binary = os.environ.get("PORTABLEMC_METHOD") == "binary" - - # Launcher command – tied directly to the method, not PATH - if using_binary: - launcher_cmd = ["portablemc"] - else: - launcher_cmd = [sys.executable, "-m", "portablemc"] - - # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) - base_dir = os.path.join(local_appdata, "PortableMC") - - # Global arguments (before 'start') - global_args = ["--main-dir", base_dir] - if not using_binary: - # Python module supports these extras - global_args.extend(["--timeout", "60", "--output", "human-color"]) - - # Start‑specific arguments - start_args = [] - if using_binary: - # Binary uses --join-server - start_args.extend(["--join-server", SERVER_IP]) - else: - # Python module uses --server - start_args.extend(["--server", SERVER_IP]) - - # JVM arguments - if using_binary: - # Binary expects --jvm-arg=OPT1,OPT2,OPT3 - jvm_opts_combined = ",".join(JVM_OPTS.split()) - start_args.append(f"--jvm-arg={jvm_opts_combined}") - else: - # Python module accepts a single --jvm-args string - start_args.extend(["--jvm-args", JVM_OPTS]) - - # Version (fabric:) - start_args.append("fabric:") - - # Username - start_args.extend(["-u", user]) - - # Custom Java path (supported by both) - java_exe = "java.exe" if os.name == "nt" else "java" - java_bin = Path.cwd() / "jvm" / "java-runtime-delta" / "bin" / java_exe - if java_bin.exists(): - # Insert --jvm and its path right after the server arguments - # Current start_args: [server args, jvm args, version, -u user] - # We want to insert before the version, so after the server args. - # Server args are two elements, so index 2. - start_args[2:2] = ["--jvm", str(java_bin)] - - # Full command - cmd = launcher_cmd + global_args + ["start"] + start_args - - # --- DISCONNECT DETECTION --- - closed_event = threading.Event() - progress_re = re.compile(r"(\d+/\d+)") - - def generate(): - process = None - got_generator_exit = False - try: - with processes_lock: - if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) - yield f"data: {busy_payload}\n\n" - yield "data: CLOSE\n\n" - return - - startupinfo = None - if os.name == 'nt': - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = subprocess.SW_HIDE - - # SAFE: shell=False, all arguments are separate list elements. - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - shell=False, - text=True, - encoding="utf-8", - errors="replace", - bufsize=1, - creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo - ) - active_processes[user] = process - logging.info(f"Started process for {user} with PID {process.pid}") - - last_progress = "" - last_send_time = time.perf_counter() - update_interval = 0.15 - ok_reached = False - - while True: - if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) - try: - yield f"data: {disc_msg}\n\n" - except (BrokenPipeError, OSError, GeneratorExit): - pass - break - - line = process.stdout.readline() - if not line: - if process.poll() is not None: - break - continue - - raw_line = line.rstrip('\n') - now = time.perf_counter() - - if not ok_reached and "[ OK ]" in raw_line: - ok_reached = True - - progress_match = progress_re.search(raw_line) - - if use_server_conversion and ansi_converter: - try: - safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) - except Exception as e: - logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) - else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) - - try: - if progress_match and not ok_reached: - current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: - yield f"data: {payload}\n\n" - last_progress = current_file - last_send_time = now - else: - yield f"data: {payload}\n\n" - except (BrokenPipeError, OSError, GeneratorExit) as e: - if isinstance(e, GeneratorExit): - got_generator_exit = True - break - - except FileNotFoundError as e: - if not closed_event.is_set(): - try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) - yield f"data: {payload}\n\n" - except Exception: - pass - except Exception as e: - if not closed_event.is_set(): - try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) - yield f"data: {payload}\n\n" - except Exception: - pass - finally: - if process: - process.stdout.close() - kill_process_tree(process) - with processes_lock: - if user in active_processes: - del active_processes[user] - logging.info(f"Removed process entry for {user}") - - if not got_generator_exit: - try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) - yield f"data: {ended_msg}\n\n" - yield f"data: {tip_msg}\n\n" - yield "data: CLOSE\n\n" - except GeneratorExit: - pass - except Exception: - pass - - response = Response(generate(), mimetype="text/event-stream") - response.call_on_close(closed_event.set) - return response - -@app.route("/") -def home(): - return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) - -def kill_process_tree(proc): - """Kill a process and all its children. Falls back to simple kill if psutil missing.""" - if proc.poll() is not None: - logging.debug(f"Process {proc.pid} already dead") - return - if PSUTIL_AVAILABLE: - try: - parent = psutil.Process(proc.pid) - children = parent.children(recursive=True) - for child in children: - try: - child.kill() - except psutil.NoSuchProcess: - pass - parent.kill() - logging.info(f"Killed process tree for PID {proc.pid}") - except psutil.NoSuchProcess: - logging.debug(f"Process {proc.pid} already gone") - else: - try: - proc.terminate() - proc.wait(timeout=2) - except subprocess.TimeoutExpired: - proc.kill() - logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") - -def graceful_shutdown(sig, frame): - logging.info("SHUTTING DOWN CORE...") - with processes_lock: - for user, proc in list(active_processes.items()): - kill_process_tree(proc) - sys.exit(0) - -signal.signal(signal.SIGINT, graceful_shutdown) - -if __name__ == "__main__": - try: - socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) - except KeyboardInterrupt: - graceful_shutdown(None, None) From 1a9bff78c425344c8b158dfe6cbccabe31ae918f Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 15:30:14 +0000 Subject: [PATCH 04/70] Remove bare exceptions for ruff linter --- main.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/main.py b/main.py index 9e1e0cb..bfca4cd 100644 --- a/main.py +++ b/main.py @@ -443,7 +443,7 @@ def get_system_python(): result = subprocess.run([str(current), "--version"], capture_output=True, text=True, timeout=2) if result.returncode == 0 and "Python 3" in result.stdout: candidates.append(current) - except: + except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): pass # From registry (both HKCU and HKLM) @@ -464,19 +464,16 @@ def get_system_python(): break winreg.CloseKey(key) - # Also check common locations (including all versions from 3.9 to 3.14) + # Common install locations versions = ["3.14", "3.13", "3.12", "3.11", "3.10", "3.9"] common_paths = [] for ver in versions: - # Standard install locations common_paths.append(f"C:\\Python{ver.replace('.', '')}\\python.exe") common_paths.append(f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe") common_paths.append(f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe") common_paths.append(f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe") - # Add Sysnative and System32 for potential 64-bit Python from WOW64 common_paths.append(r"C:\Windows\Sysnative\python.exe") common_paths.append(r"C:\Windows\System32\python.exe") - for p in common_paths: candidates.append(Path(p)) @@ -485,7 +482,7 @@ def get_system_python(): candidates.append(Path(dir) / "python.exe") candidates.append(Path(dir) / "python3.exe") - # Remove duplicates and check existence and version + # Remove duplicates and check version seen = set() valid = [] for p in candidates: @@ -496,7 +493,7 @@ def get_system_python(): if result.returncode == 0 and "Python 3" in result.stdout: version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" valid.append((version_str, p)) - except: + except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): pass if not valid: @@ -796,4 +793,4 @@ def main(): sys.exit(0 if success else 1) if __name__ == "__main__": - main() \ No newline at end of file + main() From 7cc277188dd0f56ed15924222afc12ccc92095e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Mar 2026 15:30:22 +0000 Subject: [PATCH 05/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 202 +++++++++++++++++++++++++++++------------- scripts/portablemc.py | 143 ++++++++++++++++++++++-------- 2 files changed, 247 insertions(+), 98 deletions(-) diff --git a/main.py b/main.py index bfca4cd..deb503d 100644 --- a/main.py +++ b/main.py @@ -18,7 +18,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != 'windows': +if platform.system().lower() != "windows": print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -31,7 +31,7 @@ PYTHON_VERSION = "3.14.3" PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" # Default game settings @@ -56,35 +56,36 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - 'x86_64': 'x86_64', - 'amd64': 'x86_64', - 'i686': 'i686', - 'i386': 'i686', - 'aarch64': 'aarch64', - 'arm64': 'aarch64', - 'armv7l': 'arm-gnueabihf', - 'arm': 'arm-gnueabihf', + "x86_64": "x86_64", + "amd64": "x86_64", + "i686": "i686", + "i386": "i686", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "arm-gnueabihf", + "arm": "arm-gnueabihf", } OS_MAP = { - 'windows': 'windows', - 'linux': 'linux', - 'darwin': 'macos', + "windows": "windows", + "linux": "linux", + "darwin": "macos", } + def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, 'x86_64') - if os_name == 'macos': - arch = 'aarch64' if arch == 'aarch64' else 'x86_64' - if os_name == 'linux' and arch not in ('arm-gnueabihf',): - arch += '-gnu' + arch = ARCH_MAP.get(MACHINE, "x86_64") + if os_name == "macos": + arch = "aarch64" if arch == "aarch64" else "x86_64" + if os_name == "linux" and arch not in ("arm-gnueabihf",): + arch += "-gnu" base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == 'windows': + if os_name == "windows": ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -92,6 +93,7 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename + # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -123,6 +125,7 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + # --- Junction functions --- def create_junction(source, target): """ @@ -141,7 +144,7 @@ def create_junction(source, target): if target_path.is_symlink(): # This is a junction (or a symlink) – remove only the junction itself try: - os.rmdir(str(target_path)) # works for junctions + os.rmdir(str(target_path)) # works for junctions print(f"Removed existing junction: {target_path}") except Exception as e: print(f"Warning: Could not remove junction {target_path}: {e}") @@ -159,15 +162,20 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True + check=True, + capture_output=True, + text=True, ) print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" base_dir = BASE_DIR @@ -176,6 +184,7 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- Helper functions --- def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" @@ -194,7 +203,7 @@ def ensure_embedded_python(): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(PYTHON_URL, context=ssl_context) as response: - with open(zip_path, 'wb') as out_file: + with open(zip_path, "wb") as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download Python even with unverified SSL: {e2}") @@ -207,6 +216,7 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True + def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -225,11 +235,16 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True + def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [str(EMBEDDED_PYTHON), "--version"], + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -243,6 +258,7 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False + def download_get_pip(python_exe=None): """Download get-pip.py into the embedded Python directory.""" if python_exe is None: @@ -256,7 +272,7 @@ def download_get_pip(python_exe=None): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(GET_PIP_URL, context=ssl_context) as response: - with open(pip_script, 'wb') as out_file: + with open(pip_script, "wb") as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -264,6 +280,7 @@ def download_get_pip(python_exe=None): return None return pip_script + def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -281,6 +298,7 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True + def install_pip(python_exe=None): """Install pip into the embedded Python environment.""" if python_exe is None: @@ -291,9 +309,12 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(python_exe), str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org"] + cmd = [ + str(python_exe), + str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org", + ] result = subprocess.run(cmd, env=env, capture_output=True, text=True) if result.returncode != 0: print("❌ Failed to install pip.") @@ -302,20 +323,28 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True + def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", "--upgrade", "pip"], + isolated=True, + python_exe=python_exe, + ): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe + ): print(f"❌ Failed to install {pkg}.") return False return True + def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -323,8 +352,10 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, ) path = result.stdout.strip() if path and Path(path).exists(): @@ -333,6 +364,7 @@ def get_certifi_path(python_exe=None): pass return None + def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -349,7 +381,7 @@ def download_portablemc_binary(): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(url, context=ssl_context) as response: - with open(archive_path, 'wb') as out_file: + with open(archive_path, "wb") as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download even with unverified SSL: {e2}") @@ -377,6 +409,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True + def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -388,8 +421,13 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run([str(binary_path), "--help"], env=env, - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [str(binary_path), "--help"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -412,6 +450,7 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None + def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -423,16 +462,19 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): + if run_pip_command( + ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe + ): method = test_portablemc(python_exe) if method: return method return None + # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. + Returns the path to a usable Python interpreter, or None. """ candidates = [] @@ -440,7 +482,9 @@ def get_system_python(): current = Path(sys.executable) if current.exists() and current != EMBEDDED_PYTHON: try: - result = subprocess.run([str(current), "--version"], capture_output=True, text=True, timeout=2) + result = subprocess.run( + [str(current), "--version"], capture_output=True, text=True, timeout=2 + ) if result.returncode == 0 and "Python 3" in result.stdout: candidates.append(current) except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): @@ -469,9 +513,15 @@ def get_system_python(): common_paths = [] for ver in versions: common_paths.append(f"C:\\Python{ver.replace('.', '')}\\python.exe") - common_paths.append(f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe") - common_paths.append(f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe") - common_paths.append(f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe") + common_paths.append( + f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe" + ) + common_paths.append( + f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe" + ) + common_paths.append( + f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe" + ) common_paths.append(r"C:\Windows\Sysnative\python.exe") common_paths.append(r"C:\Windows\System32\python.exe") for p in common_paths: @@ -489,7 +539,9 @@ def get_system_python(): if p.exists() and p not in seen: seen.add(p) try: - result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) + result = subprocess.run( + [str(p), "--version"], capture_output=True, text=True, timeout=2 + ) if result.returncode == 0 and "Python 3" in result.stdout: version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" valid.append((version_str, p)) @@ -505,6 +557,7 @@ def get_system_python(): print(f"Selected system Python: {best}") return best + # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -546,6 +599,7 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True + def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -563,8 +617,12 @@ def run_web_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True) + pip_check = subprocess.run( + [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, + capture_output=True, + text=True, + ) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -620,6 +678,7 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) + def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -639,7 +698,7 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f'/p:JvmOpts="{DEFAULT_JVM_OPTS}"' + f'/p:JvmOpts="{DEFAULT_JVM_OPTS}"', ] print(f"Executing: {' '.join(cmd)}") try: @@ -651,6 +710,7 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True + def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -667,8 +727,12 @@ def run_cli_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True) + pip_check = subprocess.run( + [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, + capture_output=True, + text=True, + ) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -677,7 +741,11 @@ def run_cli_launcher(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") print("📦 Installing portablemc via pip...") - if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not run_pip_command( + ["-m", "pip", "install", "portablemc"], + isolated=True, + python_exe=EMBEDDED_PYTHON, + ): print("❌ Failed to install portablemc.") return False @@ -690,19 +758,25 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), "-m", "portablemc", - "--main-dir", ".", + str(EMBEDDED_PYTHON), + "-m", + "portablemc", + "--main-dir", + ".", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness + env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness print(f"🚀 Launching: {' '.join(cmd)}") try: subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) @@ -746,13 +820,19 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(sys_python), "-m", "portablemc", - "--main-dir", ".", + str(sys_python), + "-m", + "portablemc", + "--main-dir", + ".", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -766,6 +846,7 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True + def main(): # Display menu print("\n" + "=" * 60) @@ -792,5 +873,6 @@ def main(): sys.exit(0 if success else 1) + if __name__ == "__main__": main() diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 4b73d2e..37affe4 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -23,12 +23,14 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") + def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') + return s.replace("&", "&").replace("<", "<").replace(">", ">") + # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -53,6 +55,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -65,6 +68,7 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -920,6 +924,7 @@ def escape_html(s): """ + # --- JUNCTIONS --- def create_junction(source, target): """ @@ -938,7 +943,7 @@ def create_junction(source, target): if target_path.is_symlink(): # This is a junction (or a symlink) – remove only the junction itself try: - os.rmdir(str(target_path)) # works for junctions + os.rmdir(str(target_path)) # works for junctions print(f"Removed existing junction: {target_path}") except Exception as e: print(f"Warning: Could not remove junction {target_path}: {e}") @@ -956,15 +961,20 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True + check=True, + capture_output=True, + text=True, ) print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" base_dir = BASE_DIR @@ -973,34 +983,38 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- ROUTES --- -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) + print(f"Client connected: {client_id} (Total: {connected_clients})") + emit("status", {"core": "online", "minecraft": "checking"}) -@socketio.on('disconnect') + +@socketio.on("disconnect") def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') + print(f"Client disconnected: {client_id} (Total: {connected_clients})") + -@socketio.on('ping_minecraft') +@socketio.on("ping_minecraft") def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit('minecraft_status', {'online': True}) + emit("minecraft_status", {"online": True}) except Exception: - emit('minecraft_status', {'online': False}) + emit("minecraft_status", {"online": False}) + @app.route("/ping") def ping(): @@ -1013,10 +1027,11 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- Create junctions for mods and resourcepacks (like .vbs/.ps1) --- - ensure_junctions() # This will create junctions in %LOCALAPPDATA%\PortableMC + ensure_junctions() # This will create junctions in %LOCALAPPDATA%\PortableMC # --- PORTABLEMC AVAILABILITY CHECK --- def is_portablemc_available(): @@ -1025,15 +1040,17 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): + def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -1042,31 +1059,44 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): + def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", + } + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1075,13 +1105,15 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" + "\x1b[90mPlease close the game before launching again.\x1b[0m", ] + def error_gen(): for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1096,7 +1128,9 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + local_appdata = os.environ.get( + "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") + ) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1152,13 +1186,15 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + busy_payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} + ) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == 'nt': + if os.name == "nt": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1174,7 +1210,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo + startupinfo=startupinfo, ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1186,7 +1222,12 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + disc_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", + } + ) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1199,7 +1240,7 @@ def generate(): break continue - raw_line = line.rstrip('\n') + raw_line = line.rstrip("\n") now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1210,18 +1251,23 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) + html_line = ansi_converter.convert( + safe_line, full=False + ).strip() + payload = json.dumps({"type": "html", "content": html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: + if ( + current_file != last_progress + and (now - last_send_time) > update_interval + ): yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1235,14 +1281,21 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", + } + ) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + payload = json.dumps( + {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} + ) yield f"data: {payload}\n\n" except Exception: pass @@ -1257,8 +1310,18 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + ended_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", + } + ) + tip_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", + } + ) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1271,10 +1334,12 @@ def generate(): response.call_on_close(closed_event.set) return response + @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1301,6 +1366,7 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1308,10 +1374,11 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) \ No newline at end of file + graceful_shutdown(None, None) From e4ba405675b8107bfc7eb2e02a477fa418156b35 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:28:30 +0000 Subject: [PATCH 06/70] Only fallback if the individual script fails Bug: If the Minecraft client exits the game, the launcher.targets sees this as a fallback and launches the next available script. More info: param : The term 'param' is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try again. At \portablemc-5.0.2-windows-x86_64-msvc\Scripts\Launcher.ps1:3 char:1 + param( + ~~~~~ + CategoryInfo : ObjectNotFound: (param:String) [], CommandNotFoundException + FullyQualifiedErrorId : CommandNotFoundException --- main.py | 204 +++++++++++------------------------- scripts/Launcher.targets | 28 +++-- scripts/PortableMCLoader.cs | 113 +++++++++++--------- scripts/portablemc.py | 191 ++++++--------------------------- 4 files changed, 180 insertions(+), 356 deletions(-) diff --git a/main.py b/main.py index deb503d..fd8d501 100644 --- a/main.py +++ b/main.py @@ -18,7 +18,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != "windows": +if platform.system().lower() != 'windows': print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -31,7 +31,7 @@ PYTHON_VERSION = "3.14.3" PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" # Default game settings @@ -56,36 +56,35 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - "x86_64": "x86_64", - "amd64": "x86_64", - "i686": "i686", - "i386": "i686", - "aarch64": "aarch64", - "arm64": "aarch64", - "armv7l": "arm-gnueabihf", - "arm": "arm-gnueabihf", + 'x86_64': 'x86_64', + 'amd64': 'x86_64', + 'i686': 'i686', + 'i386': 'i686', + 'aarch64': 'aarch64', + 'arm64': 'aarch64', + 'armv7l': 'arm-gnueabihf', + 'arm': 'arm-gnueabihf', } OS_MAP = { - "windows": "windows", - "linux": "linux", - "darwin": "macos", + 'windows': 'windows', + 'linux': 'linux', + 'darwin': 'macos', } - def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, "x86_64") - if os_name == "macos": - arch = "aarch64" if arch == "aarch64" else "x86_64" - if os_name == "linux" and arch not in ("arm-gnueabihf",): - arch += "-gnu" + arch = ARCH_MAP.get(MACHINE, 'x86_64') + if os_name == 'macos': + arch = 'aarch64' if arch == 'aarch64' else 'x86_64' + if os_name == 'linux' and arch not in ('arm-gnueabihf',): + arch += '-gnu' base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == "windows": + if os_name == 'windows': ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -93,7 +92,6 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename - # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -125,7 +123,6 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") - # --- Junction functions --- def create_junction(source, target): """ @@ -144,7 +141,7 @@ def create_junction(source, target): if target_path.is_symlink(): # This is a junction (or a symlink) – remove only the junction itself try: - os.rmdir(str(target_path)) # works for junctions + os.rmdir(str(target_path)) # works for junctions print(f"Removed existing junction: {target_path}") except Exception as e: print(f"Warning: Could not remove junction {target_path}: {e}") @@ -162,20 +159,15 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, - capture_output=True, - text=True, + check=True, capture_output=True, text=True ) print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print( - f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" - ) + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") target_path.mkdir(parents=True, exist_ok=True) return False - def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" base_dir = BASE_DIR @@ -184,7 +176,6 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") - # --- Helper functions --- def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" @@ -203,7 +194,7 @@ def ensure_embedded_python(): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(PYTHON_URL, context=ssl_context) as response: - with open(zip_path, "wb") as out_file: + with open(zip_path, 'wb') as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download Python even with unverified SSL: {e2}") @@ -216,7 +207,6 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True - def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -235,16 +225,11 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True - def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run( - [str(EMBEDDED_PYTHON), "--version"], - capture_output=True, - text=True, - timeout=5, - ) + result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], + capture_output=True, text=True, timeout=5) if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -258,7 +243,6 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False - def download_get_pip(python_exe=None): """Download get-pip.py into the embedded Python directory.""" if python_exe is None: @@ -272,7 +256,7 @@ def download_get_pip(python_exe=None): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(GET_PIP_URL, context=ssl_context) as response: - with open(pip_script, "wb") as out_file: + with open(pip_script, 'wb') as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -280,7 +264,6 @@ def download_get_pip(python_exe=None): return None return pip_script - def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -298,7 +281,6 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True - def install_pip(python_exe=None): """Install pip into the embedded Python environment.""" if python_exe is None: @@ -309,12 +291,9 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [ - str(python_exe), - str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org", - ] + cmd = [str(python_exe), str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org"] result = subprocess.run(cmd, env=env, capture_output=True, text=True) if result.returncode != 0: print("❌ Failed to install pip.") @@ -323,28 +302,20 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True - def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command( - ["-m", "pip", "install", "--upgrade", "pip"], - isolated=True, - python_exe=python_exe, - ): + if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command( - ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe - ): + if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): print(f"❌ Failed to install {pkg}.") return False return True - def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -352,10 +323,8 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, - text=True, - check=True, - env={"PYTHONNOUSERSITE": "1"}, + capture_output=True, text=True, check=True, + env={"PYTHONNOUSERSITE": "1"} ) path = result.stdout.strip() if path and Path(path).exists(): @@ -364,7 +333,6 @@ def get_certifi_path(python_exe=None): pass return None - def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -381,7 +349,7 @@ def download_portablemc_binary(): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(url, context=ssl_context) as response: - with open(archive_path, "wb") as out_file: + with open(archive_path, 'wb') as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download even with unverified SSL: {e2}") @@ -409,7 +377,6 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True - def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -421,13 +388,8 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run( - [str(binary_path), "--help"], - env=env, - capture_output=True, - text=True, - timeout=5, - ) + result = subprocess.run([str(binary_path), "--help"], env=env, + capture_output=True, text=True, timeout=5) if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -450,7 +412,6 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None - def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -462,19 +423,16 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command( - ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe - ): + if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): method = test_portablemc(python_exe) if method: return method return None - # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. + Returns the path to a usable Python interpreter, or None. """ candidates = [] @@ -482,9 +440,7 @@ def get_system_python(): current = Path(sys.executable) if current.exists() and current != EMBEDDED_PYTHON: try: - result = subprocess.run( - [str(current), "--version"], capture_output=True, text=True, timeout=2 - ) + result = subprocess.run([str(current), "--version"], capture_output=True, text=True, timeout=2) if result.returncode == 0 and "Python 3" in result.stdout: candidates.append(current) except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): @@ -513,15 +469,9 @@ def get_system_python(): common_paths = [] for ver in versions: common_paths.append(f"C:\\Python{ver.replace('.', '')}\\python.exe") - common_paths.append( - f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe" - ) - common_paths.append( - f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe" - ) - common_paths.append( - f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe" - ) + common_paths.append(f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe") + common_paths.append(f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe") + common_paths.append(f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe") common_paths.append(r"C:\Windows\Sysnative\python.exe") common_paths.append(r"C:\Windows\System32\python.exe") for p in common_paths: @@ -539,9 +489,7 @@ def get_system_python(): if p.exists() and p not in seen: seen.add(p) try: - result = subprocess.run( - [str(p), "--version"], capture_output=True, text=True, timeout=2 - ) + result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) if result.returncode == 0 and "Python 3" in result.stdout: version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" valid.append((version_str, p)) @@ -557,7 +505,6 @@ def get_system_python(): print(f"Selected system Python: {best}") return best - # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -599,7 +546,6 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True - def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -617,12 +563,8 @@ def run_web_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run( - [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) + pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, capture_output=True, text=True) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -678,7 +620,6 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) - def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -698,7 +639,7 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f'/p:JvmOpts="{DEFAULT_JVM_OPTS}"', + f'/p:JvmOpts="{DEFAULT_JVM_OPTS}"' ] print(f"Executing: {' '.join(cmd)}") try: @@ -710,7 +651,6 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True - def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -727,12 +667,8 @@ def run_cli_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run( - [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) + pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, capture_output=True, text=True) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -741,11 +677,7 @@ def run_cli_launcher(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") print("📦 Installing portablemc via pip...") - if not run_pip_command( - ["-m", "pip", "install", "portablemc"], - isolated=True, - python_exe=EMBEDDED_PYTHON, - ): + if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): print("❌ Failed to install portablemc.") return False @@ -758,25 +690,19 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), - "-m", - "portablemc", - "--main-dir", - ".", + str(EMBEDDED_PYTHON), "-m", "portablemc", + "--main-dir", ".", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness + env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness print(f"🚀 Launching: {' '.join(cmd)}") try: subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) @@ -820,19 +746,13 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(sys_python), - "-m", - "portablemc", - "--main-dir", - ".", + str(sys_python), "-m", "portablemc", + "--main-dir", ".", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -846,7 +766,6 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True - def main(): # Display menu print("\n" + "=" * 60) @@ -873,6 +792,5 @@ def main(): sys.exit(0 if success else 1) - if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/scripts/Launcher.targets b/scripts/Launcher.targets index 4b22e40..cffcb87 100644 --- a/scripts/Launcher.targets +++ b/scripts/Launcher.targets @@ -5,13 +5,15 @@ $(TempDir)\PortableMCLoader.cs $(TempDir)\PortableMCLoader.exe $(MSBuildProjectDirectory)\PortableMCLoader.cs + false - + + @@ -24,21 +26,33 @@ - - + true + + + + - - + true + + + + - + + true + + + + \ No newline at end of file diff --git a/scripts/PortableMCLoader.cs b/scripts/PortableMCLoader.cs index 0806e5c..e85a561 100644 --- a/scripts/PortableMCLoader.cs +++ b/scripts/PortableMCLoader.cs @@ -8,17 +8,22 @@ class Program { static void Main(string[] args) { + // Force TLS 1.2 to avoid SSL issues + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; + if (args.Length < 3) { Console.WriteLine("Usage: PortableMCLoader.exe Username ServerIp JvmOpts"); Environment.Exit(1); } + string username = args[0]; string serverIp = args[1]; string jvmOpts = args[2]; string baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PortableMC"); Directory.CreateDirectory(baseDir); + string binDir = Path.Combine(baseDir, "portablemc_bin"); string exePath = Path.Combine(binDir, "portablemc.exe"); @@ -27,52 +32,33 @@ static void Main(string[] args) Console.WriteLine("portablemc.exe not found. Downloading..."); string url = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/portablemc-5.0.2-windows-x86_64-msvc.zip"; string zipPath = Path.Combine(baseDir, "portablemc.zip"); + + if (!DownloadFile(url, zipPath)) + { + Environment.Exit(1); + } + + Console.WriteLine("Extracting..."); try { - using (WebClient client = new WebClient()) - { - client.DownloadFile(url, zipPath); - } + ZipFile.ExtractToDirectory(zipPath, binDir); } catch (Exception ex) { - Console.WriteLine("Download failed: " + ex.Message); - Console.WriteLine("Retrying with SSL disabled..."); - try - { - ServicePointManager.ServerCertificateValidationCallback = (sender, cert, chain, errors) => true; - using (WebClient client = new WebClient()) - { - client.DownloadFile(url, zipPath); - } - } - catch (Exception ex2) - { - Console.WriteLine("Download failed even with SSL disabled: " + ex2.Message); - Environment.Exit(1); - } + Console.WriteLine("Extraction failed: " + ex.Message); + Environment.Exit(1); } - - Console.WriteLine("Extracting..."); - ZipFile.ExtractToDirectory(zipPath, binDir); - File.Delete(zipPath); - foreach (string dir in Directory.GetDirectories(binDir)) + finally { - foreach (string file in Directory.GetFiles(dir)) - { - string dest = Path.Combine(binDir, Path.GetFileName(file)); - if (File.Exists(dest)) File.Delete(dest); - File.Move(file, dest); - } - Directory.Delete(dir, true); + File.Delete(zipPath); } + + // Flatten subdirectories + FlattenDirectory(binDir); Console.WriteLine("Extraction complete."); } - string scriptDir = Directory.GetCurrentDirectory(); - CreateJunction(Path.Combine(scriptDir, "mods"), Path.Combine(baseDir, "mods")); - CreateJunction(Path.Combine(scriptDir, "resourcepacks"), Path.Combine(baseDir, "resourcepacks")); - + // Build command line string jvmArgs = jvmOpts.Replace(' ', ','); string arguments = string.Format("--main-dir . start --join-server {0} --jvm-arg={1} fabric: -u {2}", serverIp, jvmArgs, username); @@ -80,8 +66,10 @@ static void Main(string[] args) Console.WriteLine(string.Format("Launching: {0} {1}", exePath, arguments)); Console.WriteLine(string.Format("Working directory: {0}", baseDir)); + // Set environment variable to avoid elevation prompts Environment.SetEnvironmentVariable("__COMPAT_LAYER", "RUNASINVOKER"); + // Start Minecraft with hidden console ProcessStartInfo psi = new ProcessStartInfo { FileName = exePath, @@ -113,23 +101,50 @@ static void Main(string[] args) } } - static void CreateJunction(string source, string target) + static bool DownloadFile(string url, string destPath) { - if (!Directory.Exists(source)) - Directory.CreateDirectory(source); - - if (Directory.Exists(target)) + try { - Directory.Delete(target, true); - Console.WriteLine(string.Format("Removed existing target: {0}", target)); + Console.WriteLine(string.Format("Downloading {0} -> {1}", url, destPath)); + using (WebClient client = new WebClient()) + { + client.DownloadFile(url, destPath); + } + return true; } + catch (Exception ex) + { + Console.WriteLine(string.Format("First download attempt failed: {0}", ex.Message)); + Console.WriteLine("Retrying with SSL verification disabled..."); + try + { + ServicePointManager.ServerCertificateValidationCallback = (sender, cert, chain, errors) => true; + using (WebClient client = new WebClient()) + { + client.DownloadFile(url, destPath); + } + return true; + } + catch (Exception ex2) + { + Console.WriteLine(string.Format("Download failed even with SSL disabled: {0}", ex2.Message)); + return false; + } + } + } - Console.WriteLine(string.Format("Creating junction: {0} -> {1}", target, source)); - Process.Start("cmd", string.Format("/c mklink /J \"{0}\" \"{1}\"", target, source)).WaitForExit(); - - if (Directory.Exists(target)) - Console.WriteLine("Junction created successfully."); - else - Console.WriteLine("Failed to create junction."); + static void FlattenDirectory(string dir) + { + if (!Directory.Exists(dir)) return; + foreach (string subDir in Directory.GetDirectories(dir)) + { + foreach (string file in Directory.GetFiles(subDir)) + { + string dest = Path.Combine(dir, Path.GetFileName(file)); + if (File.Exists(dest)) File.Delete(dest); + File.Move(file, dest); + } + Directory.Delete(subDir, true); + } } } \ No newline at end of file diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 37affe4..1518607 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -23,14 +23,12 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") - def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace("&", "&").replace("<", "<").replace(">", ">") - + return s.replace('&', '&').replace('<', '<').replace('>', '>') # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") +VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -55,7 +53,6 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil - PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -68,7 +65,6 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter - ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -924,97 +920,34 @@ def escape_html(s): """ - -# --- JUNCTIONS --- -def create_junction(source, target): - """ - Create a junction from source to target. - Safely removes any existing target before creating the junction. - Returns True if a junction was created, False otherwise (fallback to regular directory). - """ - source_path = Path(source).resolve() - target_path = Path(target).resolve() - - # Ensure source exists (create if missing, so junction has a target) - source_path.mkdir(parents=True, exist_ok=True) - - # Remove existing target if it exists - if target_path.exists(): - if target_path.is_symlink(): - # This is a junction (or a symlink) – remove only the junction itself - try: - os.rmdir(str(target_path)) # works for junctions - print(f"Removed existing junction: {target_path}") - except Exception as e: - print(f"Warning: Could not remove junction {target_path}: {e}") - elif target_path.is_dir(): - # Regular directory – delete its contents but not the source folder - if target_path == source_path: - print(f"Target {target_path} is the same as source; skipping removal.") - else: - shutil.rmtree(str(target_path), ignore_errors=True) - print(f"Removed existing directory: {target_path}") - else: - target_path.unlink() - - # Try to create junction - try: - subprocess.run( - ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, - capture_output=True, - text=True, - ) - print(f"✅ Junction created: {target_path} -> {source_path}") - return True - except subprocess.CalledProcessError as e: - print( - f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" - ) - target_path.mkdir(parents=True, exist_ok=True) - return False - - -def ensure_junctions(): - r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" - base_dir = BASE_DIR - base_dir.mkdir(parents=True, exist_ok=True) - - create_junction(ROOT_DIR / "mods", base_dir / "mods") - create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") - - # --- ROUTES --- -@socketio.on("connect") +@socketio.on('connect') def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f"Client connected: {client_id} (Total: {connected_clients})") - emit("status", {"core": "online", "minecraft": "checking"}) - + print(f'Client connected: {client_id} (Total: {connected_clients})') + emit('status', {'core': 'online', 'minecraft': 'checking'}) -@socketio.on("disconnect") +@socketio.on('disconnect') def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f"Client disconnected: {client_id} (Total: {connected_clients})") + print(f'Client disconnected: {client_id} (Total: {connected_clients})') - -@socketio.on("ping_minecraft") +@socketio.on('ping_minecraft') def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit("minecraft_status", {"online": True}) + emit('minecraft_status', {'online': True}) except Exception: - emit("minecraft_status", {"online": False}) - + emit('minecraft_status', {'online': False}) @app.route("/ping") def ping(): @@ -1027,12 +960,8 @@ def ping(): except Exception: return jsonify(online=False), 200 - @app.route("/stream") def stream(): - # --- Create junctions for mods and resourcepacks (like .vbs/.ps1) --- - ensure_junctions() # This will create junctions in %LOCALAPPDATA%\PortableMC - # --- PORTABLEMC AVAILABILITY CHECK --- def is_portablemc_available(): if shutil.which("portablemc"): @@ -1040,17 +969,15 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): - def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -1059,44 +986,31 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: - def error_gen(): - payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): - def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: - def error_gen(): - payload = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1105,15 +1019,13 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m", + "\x1b[90mPlease close the game before launching again.\x1b[0m" ] - def error_gen(): for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1128,9 +1040,7 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get( - "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") - ) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1186,15 +1096,13 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} - ) + busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == "nt": + if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1210,7 +1118,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo, + startupinfo=startupinfo ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1222,12 +1130,7 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", - } - ) + disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1240,7 +1143,7 @@ def generate(): break continue - raw_line = line.rstrip("\n") + raw_line = line.rstrip('\n') now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1251,23 +1154,18 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert( - safe_line, full=False - ).strip() - payload = json.dumps({"type": "html", "content": html_line}) + html_line = ansi_converter.convert(safe_line, full=False).strip() + payload = json.dumps({'type': 'html', 'content': html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) else: - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if ( - current_file != last_progress - and (now - last_send_time) > update_interval - ): + if current_file != last_progress and (now - last_send_time) > update_interval: yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1281,21 +1179,14 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps( - { - "type": "ansi", - "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps( - {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} - ) + payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) yield f"data: {payload}\n\n" except Exception: pass @@ -1310,18 +1201,8 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", - } - ) - tip_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", - } - ) + ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) + tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1334,12 +1215,10 @@ def generate(): response.call_on_close(closed_event.set) return response - @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) - def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1366,7 +1245,6 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") - def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1374,11 +1252,10 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) - signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) + graceful_shutdown(None, None) \ No newline at end of file From 83752a13118c4af121a991d7d352a4026f4fedad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Mar 2026 18:28:38 +0000 Subject: [PATCH 07/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 204 +++++++++++++++++++++++++++++------------- scripts/portablemc.py | 129 +++++++++++++++++++------- 2 files changed, 238 insertions(+), 95 deletions(-) diff --git a/main.py b/main.py index fd8d501..deb503d 100644 --- a/main.py +++ b/main.py @@ -18,7 +18,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != 'windows': +if platform.system().lower() != "windows": print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -31,7 +31,7 @@ PYTHON_VERSION = "3.14.3" PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" # Default game settings @@ -56,35 +56,36 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - 'x86_64': 'x86_64', - 'amd64': 'x86_64', - 'i686': 'i686', - 'i386': 'i686', - 'aarch64': 'aarch64', - 'arm64': 'aarch64', - 'armv7l': 'arm-gnueabihf', - 'arm': 'arm-gnueabihf', + "x86_64": "x86_64", + "amd64": "x86_64", + "i686": "i686", + "i386": "i686", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "arm-gnueabihf", + "arm": "arm-gnueabihf", } OS_MAP = { - 'windows': 'windows', - 'linux': 'linux', - 'darwin': 'macos', + "windows": "windows", + "linux": "linux", + "darwin": "macos", } + def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, 'x86_64') - if os_name == 'macos': - arch = 'aarch64' if arch == 'aarch64' else 'x86_64' - if os_name == 'linux' and arch not in ('arm-gnueabihf',): - arch += '-gnu' + arch = ARCH_MAP.get(MACHINE, "x86_64") + if os_name == "macos": + arch = "aarch64" if arch == "aarch64" else "x86_64" + if os_name == "linux" and arch not in ("arm-gnueabihf",): + arch += "-gnu" base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == 'windows': + if os_name == "windows": ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -92,6 +93,7 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename + # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -123,6 +125,7 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + # --- Junction functions --- def create_junction(source, target): """ @@ -141,7 +144,7 @@ def create_junction(source, target): if target_path.is_symlink(): # This is a junction (or a symlink) – remove only the junction itself try: - os.rmdir(str(target_path)) # works for junctions + os.rmdir(str(target_path)) # works for junctions print(f"Removed existing junction: {target_path}") except Exception as e: print(f"Warning: Could not remove junction {target_path}: {e}") @@ -159,15 +162,20 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True + check=True, + capture_output=True, + text=True, ) print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" base_dir = BASE_DIR @@ -176,6 +184,7 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- Helper functions --- def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" @@ -194,7 +203,7 @@ def ensure_embedded_python(): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(PYTHON_URL, context=ssl_context) as response: - with open(zip_path, 'wb') as out_file: + with open(zip_path, "wb") as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download Python even with unverified SSL: {e2}") @@ -207,6 +216,7 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True + def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -225,11 +235,16 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True + def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [str(EMBEDDED_PYTHON), "--version"], + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -243,6 +258,7 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False + def download_get_pip(python_exe=None): """Download get-pip.py into the embedded Python directory.""" if python_exe is None: @@ -256,7 +272,7 @@ def download_get_pip(python_exe=None): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(GET_PIP_URL, context=ssl_context) as response: - with open(pip_script, 'wb') as out_file: + with open(pip_script, "wb") as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -264,6 +280,7 @@ def download_get_pip(python_exe=None): return None return pip_script + def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -281,6 +298,7 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True + def install_pip(python_exe=None): """Install pip into the embedded Python environment.""" if python_exe is None: @@ -291,9 +309,12 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(python_exe), str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org"] + cmd = [ + str(python_exe), + str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org", + ] result = subprocess.run(cmd, env=env, capture_output=True, text=True) if result.returncode != 0: print("❌ Failed to install pip.") @@ -302,20 +323,28 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True + def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", "--upgrade", "pip"], + isolated=True, + python_exe=python_exe, + ): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe + ): print(f"❌ Failed to install {pkg}.") return False return True + def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -323,8 +352,10 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, ) path = result.stdout.strip() if path and Path(path).exists(): @@ -333,6 +364,7 @@ def get_certifi_path(python_exe=None): pass return None + def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -349,7 +381,7 @@ def download_portablemc_binary(): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(url, context=ssl_context) as response: - with open(archive_path, 'wb') as out_file: + with open(archive_path, "wb") as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download even with unverified SSL: {e2}") @@ -377,6 +409,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True + def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -388,8 +421,13 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run([str(binary_path), "--help"], env=env, - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [str(binary_path), "--help"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -412,6 +450,7 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None + def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -423,16 +462,19 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): + if run_pip_command( + ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe + ): method = test_portablemc(python_exe) if method: return method return None + # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. + Returns the path to a usable Python interpreter, or None. """ candidates = [] @@ -440,7 +482,9 @@ def get_system_python(): current = Path(sys.executable) if current.exists() and current != EMBEDDED_PYTHON: try: - result = subprocess.run([str(current), "--version"], capture_output=True, text=True, timeout=2) + result = subprocess.run( + [str(current), "--version"], capture_output=True, text=True, timeout=2 + ) if result.returncode == 0 and "Python 3" in result.stdout: candidates.append(current) except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): @@ -469,9 +513,15 @@ def get_system_python(): common_paths = [] for ver in versions: common_paths.append(f"C:\\Python{ver.replace('.', '')}\\python.exe") - common_paths.append(f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe") - common_paths.append(f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe") - common_paths.append(f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe") + common_paths.append( + f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe" + ) + common_paths.append( + f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe" + ) + common_paths.append( + f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe" + ) common_paths.append(r"C:\Windows\Sysnative\python.exe") common_paths.append(r"C:\Windows\System32\python.exe") for p in common_paths: @@ -489,7 +539,9 @@ def get_system_python(): if p.exists() and p not in seen: seen.add(p) try: - result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) + result = subprocess.run( + [str(p), "--version"], capture_output=True, text=True, timeout=2 + ) if result.returncode == 0 and "Python 3" in result.stdout: version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" valid.append((version_str, p)) @@ -505,6 +557,7 @@ def get_system_python(): print(f"Selected system Python: {best}") return best + # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -546,6 +599,7 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True + def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -563,8 +617,12 @@ def run_web_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True) + pip_check = subprocess.run( + [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, + capture_output=True, + text=True, + ) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -620,6 +678,7 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) + def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -639,7 +698,7 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f'/p:JvmOpts="{DEFAULT_JVM_OPTS}"' + f'/p:JvmOpts="{DEFAULT_JVM_OPTS}"', ] print(f"Executing: {' '.join(cmd)}") try: @@ -651,6 +710,7 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True + def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -667,8 +727,12 @@ def run_cli_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True) + pip_check = subprocess.run( + [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, + capture_output=True, + text=True, + ) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -677,7 +741,11 @@ def run_cli_launcher(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") print("📦 Installing portablemc via pip...") - if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not run_pip_command( + ["-m", "pip", "install", "portablemc"], + isolated=True, + python_exe=EMBEDDED_PYTHON, + ): print("❌ Failed to install portablemc.") return False @@ -690,19 +758,25 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), "-m", "portablemc", - "--main-dir", ".", + str(EMBEDDED_PYTHON), + "-m", + "portablemc", + "--main-dir", + ".", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness + env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness print(f"🚀 Launching: {' '.join(cmd)}") try: subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) @@ -746,13 +820,19 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(sys_python), "-m", "portablemc", - "--main-dir", ".", + str(sys_python), + "-m", + "portablemc", + "--main-dir", + ".", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -766,6 +846,7 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True + def main(): # Display menu print("\n" + "=" * 60) @@ -792,5 +873,6 @@ def main(): sys.exit(0 if success else 1) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 1518607..ed1915f 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -23,12 +23,14 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") + def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') + return s.replace("&", "&").replace("<", "<").replace(">", ">") + # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -53,6 +55,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -65,6 +68,7 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -920,34 +924,38 @@ def escape_html(s): """ + # --- ROUTES --- -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) + print(f"Client connected: {client_id} (Total: {connected_clients})") + emit("status", {"core": "online", "minecraft": "checking"}) + -@socketio.on('disconnect') +@socketio.on("disconnect") def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') + print(f"Client disconnected: {client_id} (Total: {connected_clients})") -@socketio.on('ping_minecraft') + +@socketio.on("ping_minecraft") def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit('minecraft_status', {'online': True}) + emit("minecraft_status", {"online": True}) except Exception: - emit('minecraft_status', {'online': False}) + emit("minecraft_status", {"online": False}) + @app.route("/ping") def ping(): @@ -960,6 +968,7 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -969,15 +978,17 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): + def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -986,31 +997,44 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): + def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", + } + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1019,13 +1043,15 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" + "\x1b[90mPlease close the game before launching again.\x1b[0m", ] + def error_gen(): for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1040,7 +1066,9 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + local_appdata = os.environ.get( + "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") + ) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1096,13 +1124,15 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + busy_payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} + ) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == 'nt': + if os.name == "nt": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1118,7 +1148,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo + startupinfo=startupinfo, ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1130,7 +1160,12 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + disc_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", + } + ) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1143,7 +1178,7 @@ def generate(): break continue - raw_line = line.rstrip('\n') + raw_line = line.rstrip("\n") now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1154,18 +1189,23 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) + html_line = ansi_converter.convert( + safe_line, full=False + ).strip() + payload = json.dumps({"type": "html", "content": html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: + if ( + current_file != last_progress + and (now - last_send_time) > update_interval + ): yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1179,14 +1219,21 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", + } + ) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + payload = json.dumps( + {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} + ) yield f"data: {payload}\n\n" except Exception: pass @@ -1201,8 +1248,18 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + ended_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", + } + ) + tip_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", + } + ) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1215,10 +1272,12 @@ def generate(): response.call_on_close(closed_event.set) return response + @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1245,6 +1304,7 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1252,10 +1312,11 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) \ No newline at end of file + graceful_shutdown(None, None) From 52d93f0a1d4e360d06a07f4739cedaef5a253d22 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:48:25 +0000 Subject: [PATCH 08/70] Update .gitignore --- .gitignore | 49 +++++++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 524f096..86e6db1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,37 @@ -# Compiled class file -*.class +# Python +__pycache__/ +*.py[cod] +*.so +*.egg-info/ +dist/ +build/ +venv/ +env/ +.env +*.egg -# Log file +# Launcher runtime +python/ +portablemc_bin/ +PortableMCLoader.exe +*.tmp *.log -# BlueJ files -*.ctxt +# User data (optional, keep if you want to exclude user‑specific files) +mods/ +resourcepacks/ +servers.dat +options.txt -# Mobile Tools for Java (J2ME) -.mtj.tmp/ +# IDE +.vscode/ +.idea/ +*.swp +*.swo -# Package Files # -*.jar -*.war -*.nar -*.ear -*.zip -*.tar.gz -*.rar +# OS +Thumbs.db +.DS_Store -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* -replay_pid* +# GitHub Actions +*.pyc From 9ea3329932bcec8f166b4fbfc0fbe5fccd90527c Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:49:02 +0000 Subject: [PATCH 09/70] Amend ruff linter into commits --- .github/workflows/main.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c323345..c65d9fc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,8 +31,9 @@ jobs: - name: Commit and push if changed if: steps.git-check.outputs.changed == 'true' run: | - git config --global user.name 'github-actions[bot]' - git config --global user.email 'github-actions[bot]@users.noreply.github.com' + # Use the original author's name and email (from the last commit) + git config --global user.name "${{ github.event.head_commit.author.name }}" + git config --global user.email "${{ github.event.head_commit.author.email }}" git add . - git commit -m "Apply automatic ruff fixes and formatting [skip ci]" + git commit -m "Apply automatic ruff fixes and formatting" git push From 03d4778b1e47b55a64290086b27bdd02f06c0c57 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:06:23 +0000 Subject: [PATCH 10/70] Actual proper README.md! Actual Proper README.md for this repository! --- README.md | 95 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index a88fd94..dc92af1 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,82 @@ -A Python Script that you can use to launch minecraft via a web browser using Flask for a portable installation using PortableMC with an additional web console. +# Minecraft Portable Web Launcher -In early development. - -Using: -* C# Code compilation -* EXE Code compilation -* Powershell Script Code as fallback -* VBS Script as fallback +A Python‑based Minecraft launcher that runs in a browser using Flask and [PortableMC](https://github.com/mindstorm38/portablemc). Designed for restricted Windows environments where arbitrary `.exe` files are blocked. It uses multiple fallback mechanisms (C# compilation, PowerShell, VBScript) to bypass group policy restrictions. ![Preview](static/preview.jpg) -Execution steps: -1. Clone this repository -```cli -git clone https://github.com/PythonChicken123/Minecraft-Portable-Web/ -``` -2. Switch the branch from main to scripts -```cli -git switch scripts +## Features + +- **Web interface** with live console (via Flask‑SocketIO) +- **CLI mode** for direct terminal launching +- **MSBuild fallback** – compiles a C# loader on‑the‑fly +- **PowerShell & VBScript fallbacks** for the most restricted environments +- **Junctions** for `mods` and `resourcepacks` (or regular directories if junctions are blocked) +- **All data stored in `%LOCALAPPDATA%\PortableMC`** – no clutter in the project folder + +## Folder Structure +```text +Minecraft-Portable-Web/ +├── main.py # Entry point – menu & bootstrapping +├── portablemc.py # Web server (Flask + SocketIO) +├── Scripts/ # Launcher helper scripts +│ ├── Launcher.targets # MSBuild task +│ ├── PortableMCLoader.cs # C# loader (compiled if needed) +│ ├── Launcher.vbs # VBScript fallback +│ └── Launcher.ps1 # PowerShell fallback +├── static/ # Static assets (logo, ansi_up, socket.io) +├── mods/ (optional) your mods +├── resourcepacks/ (optional) your resource packs +├── options.txt (optional) Minecraft settings +└── servers.dat (optional) server list ``` -3. Change into the directory -```cli + +## Requirements + +- Windows 7/8/10/11 +- Python 3.11+ (any version) OR the script will download an embedded Python 3.14. +- (Optional) MSBuild (comes with .NET Framework 4.8) – used for the C# fallback. +- (Optional) PowerShell 3.0+ – used as a fallback. +- (Optional) VBScript support – used as a final fallback. + +## Getting Started + +```bash +git clone https://github.com/PythonChicken123/Minecraft-Portable-Web/ cd Minecraft-Portable-Web -``` -4. Execute the script -```cli +git switch scripts python main.py ``` +Then choose a method: +* `1` – Web launcher (opens portablemc.py in your browser) +* `2` – MSBuild launcher (uses Launcher.targets, compiles C# loader in memory) +* `3` – CLI launcher (runs portablemc directly in the terminal) + +## How It Works (Bypass Chain) +The launcher tries the most reliable method first, falling back if blocked: + +1. Embedded Python + portablemc (if the system allows Python execution). +2. MSBuild + C# compilation (uses `csc.exe` to compile a tiny loader). +3. PowerShell (with `-ExecutionPolicy Bypass`). +4. VBScript (via `cscript`). + +If all fail, the script reports an error. + +All downloaded files (embedded Python, portablemc binary) are stored in `%LOCALAPPDATA%\PortableMC`. Junctions are created for `mods` and `resourcepacks` so that files placed in the project folder appear inside the game. + +## Development +* Linting: Ruff is used – run `ruff check .` and `ruff format .` before committing. +* Workflow: The GitHub Actions workflow (`.github/workflows/main.yml`) automatically applies Ruff fixes on push/pull requests. + +## Troubleshooting +* OSError: [WinError 1260]` – Group policy blocks the method you chose. Try another option. +* PowerShell / VBScript errors – Ensure the scripts have proper permissions. The launcher sets `__COMPAT_LAYER=RUNASINVOKER` to avoid UAC prompts. +* SSL errors – The C# loader and Python scripts force TLS 1.2 and fall back to disabling certificate validation. +* Junction creation fails – The launcher falls back to creating regular directories. -*An attempt to bypass group policy with potentional fallbacks* +## License +MIT -`OSError: [WinError 1260] This program is blocked by group policy. For more information, contact your system administrator` +## Libraries Used +* [PortableMC](https://github.com/mindstorm38/portablemc) – The Heart of The Launcher +* [Flask](https://flask.palletsprojects.com/) & [Flask‑SocketIO](https://flask-socketio.readthedocs.io/) – Web Interface +* [ansi2html](https://github.com/ralphbean/ansi2html) & [ansi_up](https://github.com/drudru/ansi_up) – ANSI colour conversion From be6b4c1718c6c828498a7e073c5f24327ab6fd23 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:08:16 +0000 Subject: [PATCH 11/70] Fix a few typos in README --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dc92af1..abea575 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ git switch scripts python main.py ``` Then choose a method: -* `1` – Web launcher (opens portablemc.py in your browser) -* `2` – MSBuild launcher (uses Launcher.targets, compiles C# loader in memory) -* `3` – CLI launcher (runs portablemc directly in the terminal) +* `1` – Web launcher (opens `portablemc.py` in your browser) +* `2` – MSBuild launcher (uses `Launcher.targets`, compiles C# loader in memory) +* `3` – CLI launcher (runs `portablemc` directly in the terminal) ## How It Works (Bypass Chain) The launcher tries the most reliable method first, falling back if blocked: @@ -68,7 +68,7 @@ All downloaded files (embedded Python, portablemc binary) are stored in `%LOCALA * Workflow: The GitHub Actions workflow (`.github/workflows/main.yml`) automatically applies Ruff fixes on push/pull requests. ## Troubleshooting -* OSError: [WinError 1260]` – Group policy blocks the method you chose. Try another option. +* OSError: `[WinError 1260]` – Group policy blocks the method you chose. Try another option. * PowerShell / VBScript errors – Ensure the scripts have proper permissions. The launcher sets `__COMPAT_LAYER=RUNASINVOKER` to avoid UAC prompts. * SSL errors – The C# loader and Python scripts force TLS 1.2 and fall back to disabling certificate validation. * Junction creation fails – The launcher falls back to creating regular directories. From 1142309992b49f09c31c3c2167aeffcc4899b217 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:09:32 +0000 Subject: [PATCH 12/70] Create MIT License --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..02bab71 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PythonChicken123 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From bd57f8b0e1f48d36092834774d9014311ef9a3e9 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:13:56 +0000 Subject: [PATCH 13/70] Add Socket.io and bolding of text into README --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index abea575..28e0618 100644 --- a/README.md +++ b/README.md @@ -54,24 +54,24 @@ Then choose a method: ## How It Works (Bypass Chain) The launcher tries the most reliable method first, falling back if blocked: -1. Embedded Python + portablemc (if the system allows Python execution). -2. MSBuild + C# compilation (uses `csc.exe` to compile a tiny loader). -3. PowerShell (with `-ExecutionPolicy Bypass`). -4. VBScript (via `cscript`). +1. **Embedded Python + portablemc** (if the system allows Python execution). +2. **MSBuild + C# compilation** (uses `csc.exe` to compile a tiny loader). +3. **PowerShell** (with `-ExecutionPolicy Bypass`). +4. **VBScript** (via `cscript`). If all fail, the script reports an error. All downloaded files (embedded Python, portablemc binary) are stored in `%LOCALAPPDATA%\PortableMC`. Junctions are created for `mods` and `resourcepacks` so that files placed in the project folder appear inside the game. ## Development -* Linting: Ruff is used – run `ruff check .` and `ruff format .` before committing. -* Workflow: The GitHub Actions workflow (`.github/workflows/main.yml`) automatically applies Ruff fixes on push/pull requests. +* **Linting:** Ruff is used – run `ruff check .` and `ruff format .` before committing. +* **Workflow:** The GitHub Actions workflow (`.github/workflows/main.yml`) automatically applies Ruff fixes on push/pull requests. ## Troubleshooting -* OSError: `[WinError 1260]` – Group policy blocks the method you chose. Try another option. -* PowerShell / VBScript errors – Ensure the scripts have proper permissions. The launcher sets `__COMPAT_LAYER=RUNASINVOKER` to avoid UAC prompts. -* SSL errors – The C# loader and Python scripts force TLS 1.2 and fall back to disabling certificate validation. -* Junction creation fails – The launcher falls back to creating regular directories. +* `OSError: [WinError 1260]` – Group policy blocks the method you chose. Try another option. +* **PowerShell / VBScript errors** – Ensure the scripts have proper permissions. The launcher sets `__COMPAT_LAYER=RUNASINVOKER` to avoid UAC prompts. +* **SSL errors** – The C# loader and Python scripts force TLS 1.2 and fall back to disabling certificate validation. +* **Junction creation fails** – The launcher falls back to creating regular directories. ## License MIT @@ -80,3 +80,4 @@ MIT * [PortableMC](https://github.com/mindstorm38/portablemc) – The Heart of The Launcher * [Flask](https://flask.palletsprojects.com/) & [Flask‑SocketIO](https://flask-socketio.readthedocs.io/) – Web Interface * [ansi2html](https://github.com/ralphbean/ansi2html) & [ansi_up](https://github.com/drudru/ansi_up) – ANSI colour conversion +* [Socket.io](https://socket.io) - Communication across Web and Flask Interface From f01a0f74bec2918b676d2dacf2a1144fd1491f54 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:22:38 +0000 Subject: [PATCH 14/70] Update the usage of libraries in README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 28e0618..9603ce7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Minecraft Portable Web Launcher -A Python‑based Minecraft launcher that runs in a browser using Flask and [PortableMC](https://github.com/mindstorm38/portablemc). Designed for restricted Windows environments where arbitrary `.exe` files are blocked. It uses multiple fallback mechanisms (C# compilation, PowerShell, VBScript) to bypass group policy restrictions. +A Python‑based Minecraft launcher that runs in a browser using [Flask](https://github.com/pallets/flask) and [PortableMC](https://github.com/mindstorm38/portablemc). Designed for restricted Windows environments where arbitrary `.exe` files are blocked. It uses multiple fallback mechanisms (C# compilation, PowerShell, VBScript) to bypass group policy restrictions. ![Preview](static/preview.jpg) @@ -81,3 +81,4 @@ MIT * [Flask](https://flask.palletsprojects.com/) & [Flask‑SocketIO](https://flask-socketio.readthedocs.io/) – Web Interface * [ansi2html](https://github.com/ralphbean/ansi2html) & [ansi_up](https://github.com/drudru/ansi_up) – ANSI colour conversion * [Socket.io](https://socket.io) - Communication across Web and Flask Interface +* [Ruff Linter](https://github.com/astral-sh/ruff) - An extremely fast Python linter From 7e91a9080c89b8026824e4a3df34027444d027ce Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:26:21 +0000 Subject: [PATCH 15/70] Only execute ruff linter if any python file has been edited --- .github/workflows/main.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c65d9fc..f34e4b8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,17 +1,25 @@ name: Ruff Auto-Fix -on: [push, pull_request] +on: + push: + paths: + - '**/*.py' + pull_request: + paths: + - '**/*.py' permissions: - contents: write # Required to push changes back + contents: write jobs: ruff: runs-on: ubuntu-latest + # Skip if the commit already contains [skip ci] + if: "!contains(github.event.head_commit.message, '[skip ci]')" steps: - name: Checkout code uses: actions/checkout@v4 with: - fetch-depth: 0 # Fetch full history to allow commits + fetch-depth: 0 - name: Install Ruff uses: astral-sh/ruff-action@v3 @@ -31,9 +39,8 @@ jobs: - name: Commit and push if changed if: steps.git-check.outputs.changed == 'true' run: | - # Use the original author's name and email (from the last commit) git config --global user.name "${{ github.event.head_commit.author.name }}" git config --global user.email "${{ github.event.head_commit.author.email }}" git add . - git commit -m "Apply automatic ruff fixes and formatting" + git commit -m "Apply automatic ruff fixes and formatting [skip ci]" git push From e31c04da27c87b7f8275d8ccad94af3844492ac9 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:37:02 +0000 Subject: [PATCH 16/70] Add TD;LR in README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 9603ce7..10db174 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,11 @@ Minecraft-Portable-Web/ - (Optional) PowerShell 3.0+ – used as a fallback. - (Optional) VBScript support – used as a final fallback. +## TL;DR +* Test in Windows Sandbox +* Test on bypassing group policy +* Sourcery Security fixes + ## Getting Started ```bash From 40d556859b4d15d08995bf7254577dcdfc4be7ea Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:42:47 +0000 Subject: [PATCH 17/70] Add suggestion/bug section in README --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 10db174..a913476 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ Minecraft-Portable-Web/ - (Optional) VBScript support – used as a final fallback. ## TL;DR -* Test in Windows Sandbox -* Test on bypassing group policy -* Sourcery Security fixes +* Test in **Windows Sandbox** +* Test on a **Restricted Environment** by **Group Policy** +* Sourcery **Security fixes** ## Getting Started @@ -78,6 +78,9 @@ All downloaded files (embedded Python, portablemc binary) are stored in `%LOCALA * **SSL errors** – The C# loader and Python scripts force TLS 1.2 and fall back to disabling certificate validation. * **Junction creation fails** – The launcher falls back to creating regular directories. +## Found a bug? / Have a suggestion? +If you encounter any issues or have an idea to improve the launcher, please [open an issue](https://github.com/PythonChicken123/Minecraft-Portable-Web/issues) and use the appropriate template. We welcome contributions! + ## License MIT From 2216dbd290889e8d0ed1c664d5bc442b8b0997e5 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:47:47 +0000 Subject: [PATCH 18/70] Create general.yml --- .github/DISCUSSION_TEMPLATE/general.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/DISCUSSION_TEMPLATE/general.yml diff --git a/.github/DISCUSSION_TEMPLATE/general.yml b/.github/DISCUSSION_TEMPLATE/general.yml new file mode 100644 index 0000000..e9c92d9 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/general.yml @@ -0,0 +1,14 @@ +title: "[General] " +labels: ["discussion"] +body: + - type: markdown + attributes: + value: | + Start a discussion about the project. This can be about usage, development, or anything else. + - type: textarea + id: topic + attributes: + label: Topic / Question + placeholder: What would you like to discuss? + validations: + required: true From a36530e806ac97fbd4cd6afbddf27e6efc5e944b Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:48:15 +0000 Subject: [PATCH 19/70] Create custom.yml --- .github/DISCUSSION_TEMPLATE/custom.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/DISCUSSION_TEMPLATE/custom.yml diff --git a/.github/DISCUSSION_TEMPLATE/custom.yml b/.github/DISCUSSION_TEMPLATE/custom.yml new file mode 100644 index 0000000..21e8357 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/custom.yml @@ -0,0 +1,15 @@ +name: Custom Issue +description: Anything else not covered by the other templates +labels: ["triage"] +body: + - type: markdown + attributes: + value: | + Please provide as much detail as possible. + - type: textarea + id: content + attributes: + label: Issue details + placeholder: Describe your issue here... + validations: + required: true From 5f711025fcfcf6745aecea2468e7fce50d30cd18 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:49:03 +0000 Subject: [PATCH 20/70] Create bug_report.yml --- .github/DISCUSSION_TEMPLATE/bug_report.yml | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/DISCUSSION_TEMPLATE/bug_report.yml diff --git a/.github/DISCUSSION_TEMPLATE/bug_report.yml b/.github/DISCUSSION_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..b5d78e4 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/bug_report.yml @@ -0,0 +1,65 @@ +name: Bug Report +description: Report a bug or unexpected behavior +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + placeholder: When I try to launch Minecraft using option X, I get an error... + validations: + required: true + - type: textarea + id: steps + attributes: + label: To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Run `python main.py` + 2. Choose option ... + 3. See error + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + - type: dropdown + id: method + attributes: + label: Which launch method did you use? + options: + - Web launcher (option 1) + - MSBuild launcher (option 2) + - CLI launcher (option 3) + validations: + required: true + - type: input + id: python_version + attributes: + label: Python version + description: Output of `python --version` + placeholder: e.g., Python 3.11.5 + validations: + required: true + - type: input + id: os_version + attributes: + label: Windows version + description: e.g., Windows 10 Pro 22H2 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Copy and paste any relevant log output. This will be automatically formatted. + render: shell From db4270dbd2b3bb0b19a761dce2e1f55eeb3be25b Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:50:03 +0000 Subject: [PATCH 21/70] Move into issue_template --- .github/{DISCUSSION_TEMPLATE => ISSUE_TEMPLATE}/custom.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{DISCUSSION_TEMPLATE => ISSUE_TEMPLATE}/custom.yml (100%) diff --git a/.github/DISCUSSION_TEMPLATE/custom.yml b/.github/ISSUE_TEMPLATE/custom.yml similarity index 100% rename from .github/DISCUSSION_TEMPLATE/custom.yml rename to .github/ISSUE_TEMPLATE/custom.yml From 816690f8bed8d87fc90baa134572af1f38d52f23 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:50:56 +0000 Subject: [PATCH 22/70] Move into issue_template --- .github/{DISCUSSION_TEMPLATE => ISSUE_TEMPLATE}/bug_report.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{DISCUSSION_TEMPLATE => ISSUE_TEMPLATE}/bug_report.yml (100%) diff --git a/.github/DISCUSSION_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml similarity index 100% rename from .github/DISCUSSION_TEMPLATE/bug_report.yml rename to .github/ISSUE_TEMPLATE/bug_report.yml From 4cdad7af36769d74c8d7defb43ec6fb54026c9ae Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:51:39 +0000 Subject: [PATCH 23/70] Create feature_request.yml --- .github/ISSUE_TEMPLATE/feature_request.yml | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..13a8dd5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,31 @@ +name: Feature Request +description: Suggest an idea for this project +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to suggest an improvement! + - type: textarea + id: problem + attributes: + label: Is your feature request related to a problem? + description: A clear description of what the problem is. + placeholder: I'm always frustrated when... + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like + description: A clear description of what you want to happen. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: Any alternative solutions or features you've considered. + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request. From 92a555c9a1403e611632ea3e915932ba4598f092 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:18:44 +0000 Subject: [PATCH 24/70] Update main.py --- main.py | 202 +++++++++++++++++--------------------------------------- 1 file changed, 60 insertions(+), 142 deletions(-) diff --git a/main.py b/main.py index deb503d..bfca4cd 100644 --- a/main.py +++ b/main.py @@ -18,7 +18,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != "windows": +if platform.system().lower() != 'windows': print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -31,7 +31,7 @@ PYTHON_VERSION = "3.14.3" PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" # Default game settings @@ -56,36 +56,35 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - "x86_64": "x86_64", - "amd64": "x86_64", - "i686": "i686", - "i386": "i686", - "aarch64": "aarch64", - "arm64": "aarch64", - "armv7l": "arm-gnueabihf", - "arm": "arm-gnueabihf", + 'x86_64': 'x86_64', + 'amd64': 'x86_64', + 'i686': 'i686', + 'i386': 'i686', + 'aarch64': 'aarch64', + 'arm64': 'aarch64', + 'armv7l': 'arm-gnueabihf', + 'arm': 'arm-gnueabihf', } OS_MAP = { - "windows": "windows", - "linux": "linux", - "darwin": "macos", + 'windows': 'windows', + 'linux': 'linux', + 'darwin': 'macos', } - def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, "x86_64") - if os_name == "macos": - arch = "aarch64" if arch == "aarch64" else "x86_64" - if os_name == "linux" and arch not in ("arm-gnueabihf",): - arch += "-gnu" + arch = ARCH_MAP.get(MACHINE, 'x86_64') + if os_name == 'macos': + arch = 'aarch64' if arch == 'aarch64' else 'x86_64' + if os_name == 'linux' and arch not in ('arm-gnueabihf',): + arch += '-gnu' base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == "windows": + if os_name == 'windows': ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -93,7 +92,6 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename - # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -125,7 +123,6 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") - # --- Junction functions --- def create_junction(source, target): """ @@ -144,7 +141,7 @@ def create_junction(source, target): if target_path.is_symlink(): # This is a junction (or a symlink) – remove only the junction itself try: - os.rmdir(str(target_path)) # works for junctions + os.rmdir(str(target_path)) # works for junctions print(f"Removed existing junction: {target_path}") except Exception as e: print(f"Warning: Could not remove junction {target_path}: {e}") @@ -162,20 +159,15 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, - capture_output=True, - text=True, + check=True, capture_output=True, text=True ) print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print( - f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" - ) + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") target_path.mkdir(parents=True, exist_ok=True) return False - def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" base_dir = BASE_DIR @@ -184,7 +176,6 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") - # --- Helper functions --- def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" @@ -203,7 +194,7 @@ def ensure_embedded_python(): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(PYTHON_URL, context=ssl_context) as response: - with open(zip_path, "wb") as out_file: + with open(zip_path, 'wb') as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download Python even with unverified SSL: {e2}") @@ -216,7 +207,6 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True - def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -235,16 +225,11 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True - def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run( - [str(EMBEDDED_PYTHON), "--version"], - capture_output=True, - text=True, - timeout=5, - ) + result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], + capture_output=True, text=True, timeout=5) if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -258,7 +243,6 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False - def download_get_pip(python_exe=None): """Download get-pip.py into the embedded Python directory.""" if python_exe is None: @@ -272,7 +256,7 @@ def download_get_pip(python_exe=None): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(GET_PIP_URL, context=ssl_context) as response: - with open(pip_script, "wb") as out_file: + with open(pip_script, 'wb') as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -280,7 +264,6 @@ def download_get_pip(python_exe=None): return None return pip_script - def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -298,7 +281,6 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True - def install_pip(python_exe=None): """Install pip into the embedded Python environment.""" if python_exe is None: @@ -309,12 +291,9 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [ - str(python_exe), - str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org", - ] + cmd = [str(python_exe), str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org"] result = subprocess.run(cmd, env=env, capture_output=True, text=True) if result.returncode != 0: print("❌ Failed to install pip.") @@ -323,28 +302,20 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True - def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command( - ["-m", "pip", "install", "--upgrade", "pip"], - isolated=True, - python_exe=python_exe, - ): + if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command( - ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe - ): + if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): print(f"❌ Failed to install {pkg}.") return False return True - def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -352,10 +323,8 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, - text=True, - check=True, - env={"PYTHONNOUSERSITE": "1"}, + capture_output=True, text=True, check=True, + env={"PYTHONNOUSERSITE": "1"} ) path = result.stdout.strip() if path and Path(path).exists(): @@ -364,7 +333,6 @@ def get_certifi_path(python_exe=None): pass return None - def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -381,7 +349,7 @@ def download_portablemc_binary(): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(url, context=ssl_context) as response: - with open(archive_path, "wb") as out_file: + with open(archive_path, 'wb') as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download even with unverified SSL: {e2}") @@ -409,7 +377,6 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True - def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -421,13 +388,8 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run( - [str(binary_path), "--help"], - env=env, - capture_output=True, - text=True, - timeout=5, - ) + result = subprocess.run([str(binary_path), "--help"], env=env, + capture_output=True, text=True, timeout=5) if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -450,7 +412,6 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None - def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -462,19 +423,16 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command( - ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe - ): + if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): method = test_portablemc(python_exe) if method: return method return None - # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. + Returns the path to a usable Python interpreter, or None. """ candidates = [] @@ -482,9 +440,7 @@ def get_system_python(): current = Path(sys.executable) if current.exists() and current != EMBEDDED_PYTHON: try: - result = subprocess.run( - [str(current), "--version"], capture_output=True, text=True, timeout=2 - ) + result = subprocess.run([str(current), "--version"], capture_output=True, text=True, timeout=2) if result.returncode == 0 and "Python 3" in result.stdout: candidates.append(current) except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): @@ -513,15 +469,9 @@ def get_system_python(): common_paths = [] for ver in versions: common_paths.append(f"C:\\Python{ver.replace('.', '')}\\python.exe") - common_paths.append( - f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe" - ) - common_paths.append( - f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe" - ) - common_paths.append( - f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe" - ) + common_paths.append(f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe") + common_paths.append(f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe") + common_paths.append(f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe") common_paths.append(r"C:\Windows\Sysnative\python.exe") common_paths.append(r"C:\Windows\System32\python.exe") for p in common_paths: @@ -539,9 +489,7 @@ def get_system_python(): if p.exists() and p not in seen: seen.add(p) try: - result = subprocess.run( - [str(p), "--version"], capture_output=True, text=True, timeout=2 - ) + result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) if result.returncode == 0 and "Python 3" in result.stdout: version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" valid.append((version_str, p)) @@ -557,7 +505,6 @@ def get_system_python(): print(f"Selected system Python: {best}") return best - # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -599,7 +546,6 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True - def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -617,12 +563,8 @@ def run_web_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run( - [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) + pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, capture_output=True, text=True) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -678,7 +620,6 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) - def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -698,7 +639,7 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f'/p:JvmOpts="{DEFAULT_JVM_OPTS}"', + f'/p:JvmOpts="{DEFAULT_JVM_OPTS}"' ] print(f"Executing: {' '.join(cmd)}") try: @@ -710,7 +651,6 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True - def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -727,12 +667,8 @@ def run_cli_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run( - [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) + pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, capture_output=True, text=True) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -741,11 +677,7 @@ def run_cli_launcher(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") print("📦 Installing portablemc via pip...") - if not run_pip_command( - ["-m", "pip", "install", "portablemc"], - isolated=True, - python_exe=EMBEDDED_PYTHON, - ): + if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): print("❌ Failed to install portablemc.") return False @@ -758,25 +690,19 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), - "-m", - "portablemc", - "--main-dir", - ".", + str(EMBEDDED_PYTHON), "-m", "portablemc", + "--main-dir", ".", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness + env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness print(f"🚀 Launching: {' '.join(cmd)}") try: subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) @@ -820,19 +746,13 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(sys_python), - "-m", - "portablemc", - "--main-dir", - ".", + str(sys_python), "-m", "portablemc", + "--main-dir", ".", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -846,7 +766,6 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True - def main(): # Display menu print("\n" + "=" * 60) @@ -873,6 +792,5 @@ def main(): sys.exit(0 if success else 1) - if __name__ == "__main__": main() From 72f11c77b8925eebdfb95bc1c36d321eddc394ca Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:18:53 +0000 Subject: [PATCH 25/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 202 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 142 insertions(+), 60 deletions(-) diff --git a/main.py b/main.py index bfca4cd..deb503d 100644 --- a/main.py +++ b/main.py @@ -18,7 +18,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != 'windows': +if platform.system().lower() != "windows": print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -31,7 +31,7 @@ PYTHON_VERSION = "3.14.3" PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" # Default game settings @@ -56,35 +56,36 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - 'x86_64': 'x86_64', - 'amd64': 'x86_64', - 'i686': 'i686', - 'i386': 'i686', - 'aarch64': 'aarch64', - 'arm64': 'aarch64', - 'armv7l': 'arm-gnueabihf', - 'arm': 'arm-gnueabihf', + "x86_64": "x86_64", + "amd64": "x86_64", + "i686": "i686", + "i386": "i686", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "arm-gnueabihf", + "arm": "arm-gnueabihf", } OS_MAP = { - 'windows': 'windows', - 'linux': 'linux', - 'darwin': 'macos', + "windows": "windows", + "linux": "linux", + "darwin": "macos", } + def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, 'x86_64') - if os_name == 'macos': - arch = 'aarch64' if arch == 'aarch64' else 'x86_64' - if os_name == 'linux' and arch not in ('arm-gnueabihf',): - arch += '-gnu' + arch = ARCH_MAP.get(MACHINE, "x86_64") + if os_name == "macos": + arch = "aarch64" if arch == "aarch64" else "x86_64" + if os_name == "linux" and arch not in ("arm-gnueabihf",): + arch += "-gnu" base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == 'windows': + if os_name == "windows": ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -92,6 +93,7 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename + # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -123,6 +125,7 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + # --- Junction functions --- def create_junction(source, target): """ @@ -141,7 +144,7 @@ def create_junction(source, target): if target_path.is_symlink(): # This is a junction (or a symlink) – remove only the junction itself try: - os.rmdir(str(target_path)) # works for junctions + os.rmdir(str(target_path)) # works for junctions print(f"Removed existing junction: {target_path}") except Exception as e: print(f"Warning: Could not remove junction {target_path}: {e}") @@ -159,15 +162,20 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True + check=True, + capture_output=True, + text=True, ) print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" base_dir = BASE_DIR @@ -176,6 +184,7 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- Helper functions --- def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" @@ -194,7 +203,7 @@ def ensure_embedded_python(): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(PYTHON_URL, context=ssl_context) as response: - with open(zip_path, 'wb') as out_file: + with open(zip_path, "wb") as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download Python even with unverified SSL: {e2}") @@ -207,6 +216,7 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True + def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -225,11 +235,16 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True + def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [str(EMBEDDED_PYTHON), "--version"], + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -243,6 +258,7 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False + def download_get_pip(python_exe=None): """Download get-pip.py into the embedded Python directory.""" if python_exe is None: @@ -256,7 +272,7 @@ def download_get_pip(python_exe=None): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(GET_PIP_URL, context=ssl_context) as response: - with open(pip_script, 'wb') as out_file: + with open(pip_script, "wb") as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -264,6 +280,7 @@ def download_get_pip(python_exe=None): return None return pip_script + def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -281,6 +298,7 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True + def install_pip(python_exe=None): """Install pip into the embedded Python environment.""" if python_exe is None: @@ -291,9 +309,12 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(python_exe), str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org"] + cmd = [ + str(python_exe), + str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org", + ] result = subprocess.run(cmd, env=env, capture_output=True, text=True) if result.returncode != 0: print("❌ Failed to install pip.") @@ -302,20 +323,28 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True + def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", "--upgrade", "pip"], + isolated=True, + python_exe=python_exe, + ): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe + ): print(f"❌ Failed to install {pkg}.") return False return True + def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -323,8 +352,10 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, ) path = result.stdout.strip() if path and Path(path).exists(): @@ -333,6 +364,7 @@ def get_certifi_path(python_exe=None): pass return None + def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -349,7 +381,7 @@ def download_portablemc_binary(): try: ssl_context = ssl._create_unverified_context() with urllib.request.urlopen(url, context=ssl_context) as response: - with open(archive_path, 'wb') as out_file: + with open(archive_path, "wb") as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download even with unverified SSL: {e2}") @@ -377,6 +409,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True + def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -388,8 +421,13 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run([str(binary_path), "--help"], env=env, - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [str(binary_path), "--help"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -412,6 +450,7 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None + def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -423,16 +462,19 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): + if run_pip_command( + ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe + ): method = test_portablemc(python_exe) if method: return method return None + # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. + Returns the path to a usable Python interpreter, or None. """ candidates = [] @@ -440,7 +482,9 @@ def get_system_python(): current = Path(sys.executable) if current.exists() and current != EMBEDDED_PYTHON: try: - result = subprocess.run([str(current), "--version"], capture_output=True, text=True, timeout=2) + result = subprocess.run( + [str(current), "--version"], capture_output=True, text=True, timeout=2 + ) if result.returncode == 0 and "Python 3" in result.stdout: candidates.append(current) except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): @@ -469,9 +513,15 @@ def get_system_python(): common_paths = [] for ver in versions: common_paths.append(f"C:\\Python{ver.replace('.', '')}\\python.exe") - common_paths.append(f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe") - common_paths.append(f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe") - common_paths.append(f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe") + common_paths.append( + f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe" + ) + common_paths.append( + f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe" + ) + common_paths.append( + f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe" + ) common_paths.append(r"C:\Windows\Sysnative\python.exe") common_paths.append(r"C:\Windows\System32\python.exe") for p in common_paths: @@ -489,7 +539,9 @@ def get_system_python(): if p.exists() and p not in seen: seen.add(p) try: - result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) + result = subprocess.run( + [str(p), "--version"], capture_output=True, text=True, timeout=2 + ) if result.returncode == 0 and "Python 3" in result.stdout: version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" valid.append((version_str, p)) @@ -505,6 +557,7 @@ def get_system_python(): print(f"Selected system Python: {best}") return best + # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -546,6 +599,7 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True + def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -563,8 +617,12 @@ def run_web_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True) + pip_check = subprocess.run( + [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, + capture_output=True, + text=True, + ) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -620,6 +678,7 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) + def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -639,7 +698,7 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f'/p:JvmOpts="{DEFAULT_JVM_OPTS}"' + f'/p:JvmOpts="{DEFAULT_JVM_OPTS}"', ] print(f"Executing: {' '.join(cmd)}") try: @@ -651,6 +710,7 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True + def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -667,8 +727,12 @@ def run_cli_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True) + pip_check = subprocess.run( + [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, + capture_output=True, + text=True, + ) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -677,7 +741,11 @@ def run_cli_launcher(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") print("📦 Installing portablemc via pip...") - if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not run_pip_command( + ["-m", "pip", "install", "portablemc"], + isolated=True, + python_exe=EMBEDDED_PYTHON, + ): print("❌ Failed to install portablemc.") return False @@ -690,19 +758,25 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), "-m", "portablemc", - "--main-dir", ".", + str(EMBEDDED_PYTHON), + "-m", + "portablemc", + "--main-dir", + ".", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness + env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness print(f"🚀 Launching: {' '.join(cmd)}") try: subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) @@ -746,13 +820,19 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(sys_python), "-m", "portablemc", - "--main-dir", ".", + str(sys_python), + "-m", + "portablemc", + "--main-dir", + ".", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -766,6 +846,7 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True + def main(): # Display menu print("\n" + "=" * 60) @@ -792,5 +873,6 @@ def main(): sys.exit(0 if success else 1) + if __name__ == "__main__": main() From 311b51d8ea1c71c0c08cac5915ad42a49c03510c Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:22:37 +0000 Subject: [PATCH 26/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/main.py b/main.py index 14e39ed..deb503d 100644 --- a/main.py +++ b/main.py @@ -876,4 +876,3 @@ def main(): if __name__ == "__main__": main() - \ No newline at end of file From 5378080d7eebdf56fab69d7fc352d7f6ebd28097 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:02:11 +0000 Subject: [PATCH 27/70] Update main.yml --- .github/workflows/main.yml | 46 +++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f34e4b8..45fd59e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,12 +9,12 @@ on: permissions: contents: write + pull-requests: write jobs: - ruff: + push-fix: + if: github.event_name == 'push' && !contains(join(github.event.commits.*.message, ' '), '[skip ci]') runs-on: ubuntu-latest - # Skip if the commit already contains [skip ci] - if: "!contains(github.event.head_commit.message, '[skip ci]')" steps: - name: Checkout code uses: actions/checkout@v4 @@ -39,8 +39,44 @@ jobs: - name: Commit and push if changed if: steps.git-check.outputs.changed == 'true' run: | - git config --global user.name "${{ github.event.head_commit.author.name }}" - git config --global user.email "${{ github.event.head_commit.author.email }}" + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' git add . git commit -m "Apply automatic ruff fixes and formatting [skip ci]" git push + + pr-comment: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Ruff + uses: astral-sh/ruff-action@v3 + with: + version: "latest" + + - name: Fix linting issues and format + run: | + ruff check --fix . + ruff format . + + - name: Check for changes + id: git-check + run: | + git diff --quiet || echo "changed=true" >> $GITHUB_OUTPUT + + - name: Comment on PR if changed + if: steps.git-check.outputs.changed == 'true' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '⚠️ **Ruff auto-fix applied**\n\nLinting and formatting changes were automatically applied. Please pull the latest changes or update your branch.' + }) From 56f0f4a861bec16351b34035de889c1223f78b09 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:08:22 +0000 Subject: [PATCH 28/70] Apply suggestions from code review Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a913476..9b506f5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Minecraft Portable Web Launcher -A Python‑based Minecraft launcher that runs in a browser using [Flask](https://github.com/pallets/flask) and [PortableMC](https://github.com/mindstorm38/portablemc). Designed for restricted Windows environments where arbitrary `.exe` files are blocked. It uses multiple fallback mechanisms (C# compilation, PowerShell, VBScript) to bypass group policy restrictions. +A Python‑based Minecraft launcher that runs in a browser using [Flask](https://github.com/pallets/flask) and [PortableMC](https://github.com/mindstorm38/portablemc). Designed for restricted Windows environments where arbitrary `.exe` files are blocked. It uses multiple fallback mechanisms (C# compilation, PowerShell, VBScript) to bypass Group Policy restrictions. ![Preview](static/preview.jpg) @@ -88,5 +88,5 @@ MIT * [PortableMC](https://github.com/mindstorm38/portablemc) – The Heart of The Launcher * [Flask](https://flask.palletsprojects.com/) & [Flask‑SocketIO](https://flask-socketio.readthedocs.io/) – Web Interface * [ansi2html](https://github.com/ralphbean/ansi2html) & [ansi_up](https://github.com/drudru/ansi_up) – ANSI colour conversion -* [Socket.io](https://socket.io) - Communication across Web and Flask Interface +* [Socket.io](https://socket.io) - Communication between the web and Flask interfaces * [Ruff Linter](https://github.com/astral-sh/ruff) - An extremely fast Python linter From 7918d154927f8ecfea13db88e8998e39736894bf Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:18:56 +0000 Subject: [PATCH 29/70] Amend Sourcery-AI suggested fixes --- main.py | 357 +++++++++++++++--------------------- scripts/Launcher.ps1 | 31 +--- scripts/Launcher.vbs | 27 +-- scripts/PortableMCLoader.cs | 37 ++-- scripts/portablemc.py | 129 ++++--------- 5 files changed, 206 insertions(+), 375 deletions(-) diff --git a/main.py b/main.py index deb503d..8dc56f5 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,7 @@ import os import sys import subprocess +import stat import urllib.request import zipfile import tarfile @@ -18,7 +19,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != "windows": +if platform.system().lower() != 'windows': print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -29,10 +30,12 @@ EMBEDDED_DIR = BASE_DIR / "python" EMBEDDED_PYTHON = EMBEDDED_DIR / "python.exe" PYTHON_VERSION = "3.14.3" +PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -56,36 +59,35 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - "x86_64": "x86_64", - "amd64": "x86_64", - "i686": "i686", - "i386": "i686", - "aarch64": "aarch64", - "arm64": "aarch64", - "armv7l": "arm-gnueabihf", - "arm": "arm-gnueabihf", + 'x86_64': 'x86_64', + 'amd64': 'x86_64', + 'i686': 'i686', + 'i386': 'i686', + 'aarch64': 'aarch64', + 'arm64': 'aarch64', + 'armv7l': 'arm-gnueabihf', + 'arm': 'arm-gnueabihf', } OS_MAP = { - "windows": "windows", - "linux": "linux", - "darwin": "macos", + 'windows': 'windows', + 'linux': 'linux', + 'darwin': 'macos', } - def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, "x86_64") - if os_name == "macos": - arch = "aarch64" if arch == "aarch64" else "x86_64" - if os_name == "linux" and arch not in ("arm-gnueabihf",): - arch += "-gnu" + arch = ARCH_MAP.get(MACHINE, 'x86_64') + if os_name == 'macos': + arch = 'aarch64' if arch == 'aarch64' else 'x86_64' + if os_name == 'linux' and arch not in ('arm-gnueabihf',): + arch += '-gnu' base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == "windows": + if os_name == 'windows': ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -93,7 +95,6 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename - # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -125,8 +126,15 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") - # --- Junction functions --- +def is_junction(path): + """Return True if path is a junction (reparse point).""" + try: + attrs = os.lstat(str(path)) + return (attrs.st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT) != 0 + except OSError: + return False + def create_junction(source, target): """ Create a junction from source to target. @@ -141,20 +149,13 @@ def create_junction(source, target): # Remove existing target if it exists if target_path.exists(): - if target_path.is_symlink(): - # This is a junction (or a symlink) – remove only the junction itself - try: - os.rmdir(str(target_path)) # works for junctions - print(f"Removed existing junction: {target_path}") - except Exception as e: - print(f"Warning: Could not remove junction {target_path}: {e}") + if is_junction(target_path): + os.rmdir(str(target_path)) # removes only the junction + print(f"Removed existing junction: {target_path}") elif target_path.is_dir(): - # Regular directory – delete its contents but not the source folder - if target_path == source_path: - print(f"Target {target_path} is the same as source; skipping removal.") - else: - shutil.rmtree(str(target_path), ignore_errors=True) - print(f"Removed existing directory: {target_path}") + # Regular directory – delete contents + shutil.rmtree(str(target_path), ignore_errors=True) + print(f"Removed existing directory: {target_path}") else: target_path.unlink() @@ -162,20 +163,15 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, - capture_output=True, - text=True, + check=True, capture_output=True, text=True ) print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print( - f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" - ) + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") target_path.mkdir(parents=True, exist_ok=True) return False - def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" base_dir = BASE_DIR @@ -184,6 +180,13 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") +# --- Download functions --- +def get_ssl_context(): + """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" + if ALLOW_INSECURE_SSL: + print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") + return ssl._create_unverified_context() + return None # --- Helper functions --- def ensure_embedded_python(): @@ -199,14 +202,18 @@ def ensure_embedded_python(): urllib.request.urlretrieve(PYTHON_URL, zip_path) except Exception as e: print(f"⚠️ First download attempt failed: {e}") - print("📥 Retrying with SSL verification disabled (insecure)...") - try: - ssl_context = ssl._create_unverified_context() - with urllib.request.urlopen(PYTHON_URL, context=ssl_context) as response: - with open(zip_path, "wb") as out_file: - out_file.write(response.read()) - except Exception as e2: - print(f"❌ Failed to download Python even with unverified SSL: {e2}") + if ALLOW_INSECURE_SSL: + print("📥 Retrying with SSL verification disabled...") + try: + context = get_ssl_context() + with urllib.request.urlopen(PYTHON_URL, context=context) as response: + with open(zip_path, 'wb') as out_file: + out_file.write(response.read()) + except Exception as e2: + print(f"❌ Failed to download Python even with unverified SSL: {e2}") + return False + else: + print("❌ Download failed and insecure SSL is disabled.") return False print("📦 Extracting...") @@ -216,7 +223,6 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True - def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -235,16 +241,11 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True - def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run( - [str(EMBEDDED_PYTHON), "--version"], - capture_output=True, - text=True, - timeout=5, - ) + result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], + capture_output=True, text=True, timeout=5) if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -258,11 +259,8 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False - -def download_get_pip(python_exe=None): +def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" - if python_exe is None: - python_exe = EMBEDDED_PYTHON pip_script = EMBEDDED_DIR / "get-pip.py" if pip_script.exists(): print("✅ get-pip.py already present.") @@ -270,9 +268,9 @@ def download_get_pip(python_exe=None): print("📥 Downloading get-pip.py (ignoring SSL cert for this request)...") try: - ssl_context = ssl._create_unverified_context() - with urllib.request.urlopen(GET_PIP_URL, context=ssl_context) as response: - with open(pip_script, "wb") as out_file: + context = get_ssl_context() # from earlier (secure or insecure) + with urllib.request.urlopen(GET_PIP_URL, context=context) as response: + with open(pip_script, 'wb') as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -280,7 +278,6 @@ def download_get_pip(python_exe=None): return None return pip_script - def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -298,23 +295,20 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True - def install_pip(python_exe=None): - """Install pip into the embedded Python environment.""" + """Install pip into the given Python environment.""" if python_exe is None: python_exe = EMBEDDED_PYTHON - pip_script = download_get_pip(python_exe) + pip_script = download_get_pip() if not pip_script: return False + env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [ - str(python_exe), - str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org", - ] + cmd = [str(python_exe), str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org"] result = subprocess.run(cmd, env=env, capture_output=True, text=True) if result.returncode != 0: print("❌ Failed to install pip.") @@ -323,28 +317,20 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True - def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command( - ["-m", "pip", "install", "--upgrade", "pip"], - isolated=True, - python_exe=python_exe, - ): + if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command( - ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe - ): + if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): print(f"❌ Failed to install {pkg}.") return False return True - def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -352,10 +338,8 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, - text=True, - check=True, - env={"PYTHONNOUSERSITE": "1"}, + capture_output=True, text=True, check=True, + env={"PYTHONNOUSERSITE": "1"} ) path = result.stdout.strip() if path and Path(path).exists(): @@ -364,7 +348,6 @@ def get_certifi_path(python_exe=None): pass return None - def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -377,14 +360,18 @@ def download_portablemc_binary(): urllib.request.urlretrieve(url, archive_path) except Exception as e: print(f"⚠️ First download attempt failed: {e}") - print("📥 Retrying with SSL verification disabled (insecure)...") - try: - ssl_context = ssl._create_unverified_context() - with urllib.request.urlopen(url, context=ssl_context) as response: - with open(archive_path, "wb") as out_file: - out_file.write(response.read()) - except Exception as e2: - print(f"❌ Failed to download even with unverified SSL: {e2}") + if ALLOW_INSECURE_SSL: + print("📥 Retrying with SSL verification disabled...") + try: + context = get_ssl_context() + with urllib.request.urlopen(url, context=context) as response: + with open(archive_path, 'wb') as out_file: + out_file.write(response.read()) + except Exception as e2: + print(f"❌ Failed to download even with unverified SSL: {e2}") + return False + else: + print("❌ Download failed and insecure SSL is disabled.") return False PORTABLEMC_BIN_DIR.mkdir(exist_ok=True) @@ -409,7 +396,6 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True - def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -421,13 +407,8 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run( - [str(binary_path), "--help"], - env=env, - capture_output=True, - text=True, - timeout=5, - ) + result = subprocess.run([str(binary_path), "--help"], env=env, + capture_output=True, text=True, timeout=5) if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -450,7 +431,6 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None - def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -462,35 +442,38 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command( - ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe - ): + if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): method = test_portablemc(python_exe) if method: return method return None - # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] + seen = set() - # Add the current interpreter (if it's not the embedded Python we're trying to set up) + def add_candidate(p): + p = Path(p).resolve() + if p.exists() and p not in seen: + seen.add(p) + candidates.append(p) + + # 1. Current interpreter (if it's not the embedded one) current = Path(sys.executable) - if current.exists() and current != EMBEDDED_PYTHON: - try: - result = subprocess.run( - [str(current), "--version"], capture_output=True, text=True, timeout=2 - ) - if result.returncode == 0 and "Python 3" in result.stdout: - candidates.append(current) - except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): - pass + if current != EMBEDDED_PYTHON: + add_candidate(current) + + # 2. Search PATH (most likely to be user's preferred Python) + for dir in os.environ.get("PATH", "").split(os.pathsep): + add_candidate(Path(dir) / "python.exe") + add_candidate(Path(dir) / "python3.exe") - # From registry (both HKCU and HKLM) + # 3. Registry (both HKCU and HKLM) for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): try: key = winreg.OpenKey(hive, r"Software\Python\PythonCore") @@ -502,51 +485,35 @@ def get_system_python(): ver = winreg.EnumKey(key, i) if ver.startswith("3."): install_path = winreg.QueryValue(key, f"{ver}\\InstallPath") - candidates.append(Path(install_path) / "python.exe") + add_candidate(Path(install_path) / "python.exe") i += 1 except WindowsError: break winreg.CloseKey(key) - # Common install locations - versions = ["3.14", "3.13", "3.12", "3.11", "3.10", "3.9"] - common_paths = [] - for ver in versions: - common_paths.append(f"C:\\Python{ver.replace('.', '')}\\python.exe") - common_paths.append( - f"C:\\Users\\{os.environ.get('USERNAME')}\\AppData\\Local\\Programs\\Python\\Python{ver.replace('.', '')}\\python.exe" - ) - common_paths.append( - f"C:\\Program Files\\Python{ver.replace('.', '')}\\python.exe" - ) - common_paths.append( - f"C:\\Program Files (x86)\\Python{ver.replace('.', '')}\\python.exe" - ) - common_paths.append(r"C:\Windows\Sysnative\python.exe") - common_paths.append(r"C:\Windows\System32\python.exe") - for p in common_paths: - candidates.append(Path(p)) - - # Also search PATH - for dir in os.environ.get("PATH", "").split(os.pathsep): - candidates.append(Path(dir) / "python.exe") - candidates.append(Path(dir) / "python3.exe") - - # Remove duplicates and check version - seen = set() + # 4. Common install locations (static paths) + for ver in PYTHON_VERSIONS: + num = ver.replace('.', '') + # System-wide installations + for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): + add_candidate(base.format(num) + "\\python.exe") + # User installations + user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" + add_candidate(user_dir / "python.exe") + # Sysnative and System32 (often contain a 64-bit Python from WOW64) + add_candidate(r"C:\Windows\Sysnative\python.exe") + add_candidate(r"C:\Windows\System32\python.exe") + + # Now verify each candidate and extract version valid = [] for p in candidates: - if p.exists() and p not in seen: - seen.add(p) - try: - result = subprocess.run( - [str(p), "--version"], capture_output=True, text=True, timeout=2 - ) - if result.returncode == 0 and "Python 3" in result.stdout: - version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" - valid.append((version_str, p)) - except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): - pass + try: + result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) + if result.returncode == 0 and "Python 3" in result.stdout: + version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" + valid.append((version_str, p)) + except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): + continue if not valid: return None @@ -557,7 +524,6 @@ def get_system_python(): print(f"Selected system Python: {best}") return best - # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -599,7 +565,6 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True - def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -617,12 +582,8 @@ def run_web_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run( - [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) + pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, capture_output=True, text=True) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -678,7 +639,6 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) - def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -692,13 +652,15 @@ def run_msbuild_launcher(): env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" + if ALLOW_INSECURE_SSL: + env["ALLOW_INSECURE_SSL"] = "true" cmd = [ MSBUILD_PATH, str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f'/p:JvmOpts="{DEFAULT_JVM_OPTS}"', + f"/p:JvmOpts={DEFAULT_JVM_OPTS}" ] print(f"Executing: {' '.join(cmd)}") try: @@ -710,7 +672,6 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True - def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -727,12 +688,8 @@ def run_cli_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run( - [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) + pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, capture_output=True, text=True) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -741,11 +698,7 @@ def run_cli_launcher(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") print("📦 Installing portablemc via pip...") - if not run_pip_command( - ["-m", "pip", "install", "portablemc"], - isolated=True, - python_exe=EMBEDDED_PYTHON, - ): + if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): print("❌ Failed to install portablemc.") return False @@ -758,25 +711,19 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), - "-m", - "portablemc", - "--main-dir", - ".", + str(EMBEDDED_PYTHON), "-m", "portablemc", + "--main-dir", ".", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness + env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness print(f"🚀 Launching: {' '.join(cmd)}") try: subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) @@ -820,19 +767,13 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(sys_python), - "-m", - "portablemc", - "--main-dir", - ".", + str(sys_python), "-m", "portablemc", + "--main-dir", ".", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -846,7 +787,6 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True - def main(): # Display menu print("\n" + "=" * 60) @@ -873,6 +813,5 @@ def main(): sys.exit(0 if success else 1) - if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/scripts/Launcher.ps1 b/scripts/Launcher.ps1 index b57b72b..a79b32f 100644 --- a/scripts/Launcher.ps1 +++ b/scripts/Launcher.ps1 @@ -7,7 +7,7 @@ param( ) # Fallback to positional arguments if needed -if (-not $JvmOpts -and $args.Count -ge 0) { +if (-not $JvmOpts -and $args.Count -gt 0) { $JvmOpts = $args[0] } @@ -26,31 +26,6 @@ $baseDir = Join-Path $env:LOCALAPPDATA "PortableMC" $binDir = Join-Path $baseDir "portablemc_bin" $exePath = Join-Path $binDir "portablemc.exe" -# --- Function to create a junction, removing any existing folder/link first --- -function Ensure-Junction { - param( - [string]$Source, - [string]$Target - ) - # Create source folder if it doesn't exist (so junction has a target) - if (-not (Test-Path $Source)) { - New-Item -ItemType Directory -Path $Source -Force | Out-Null - Write-Host "Created source folder: $Source" - } - # If target already exists, remove it (whether it's a folder or a junction) - if (Test-Path $Target) { - Remove-Item -Path $Target -Recurse -Force - Write-Host "Removed existing target: $Target" - } - Write-Host "Creating junction: $Target -> $Source" - cmd /c mklink /J "$Target" "$Source" 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Host "Junction created successfully." - } else { - Write-Host "Failed to create junction. Ensure source exists and you're on the same drive." - } -} - # --- Ensure the base directory exists --- if (-not (Test-Path $baseDir)) { New-Item -ItemType Directory -Path $baseDir -Force | Out-Null @@ -87,10 +62,6 @@ if (-not (Test-Path $exePath)) { } } -# --- Create junctions for mods and resourcepacks (force recreation) --- -Ensure-Junction -Source (Join-Path $rootDir "mods") -Target (Join-Path $baseDir "mods") -Ensure-Junction -Source (Join-Path $rootDir "resourcepacks") -Target (Join-Path $baseDir "resourcepacks") - # --- Process JVM options and launch --- $jvmArgs = $JvmOpts -replace ' ', ',' $arguments = "--main-dir . start --join-server $ServerIp --jvm-arg=$jvmArgs fabric: -u $Username" diff --git a/scripts/Launcher.vbs b/scripts/Launcher.vbs index d94a239..0a4c86e 100644 --- a/scripts/Launcher.vbs +++ b/scripts/Launcher.vbs @@ -1,6 +1,6 @@ ' Launcher.vbs ' Usage: cscript Launcher.vbs -' Stores all files in %LOCALAPPDATA%\PortableMC and forces junction recreation for mods/resourcepacks. +' Stores all files in %LOCALAPPDATA%\PortableMC Set args = WScript.Arguments If args.Count < 3 Then @@ -29,27 +29,6 @@ If Not fso.FolderExists(baseDir) Then fso.CreateFolder(baseDir) binDir = fso.BuildPath(baseDir, "portablemc_bin") exePath = fso.BuildPath(binDir, "portablemc.exe") -' --- Helper to create a junction, removing any existing folder/link first --- -Sub CreateJunction(source, target) - ' Ensure source folder exists (create if missing) - If Not fso.FolderExists(source) Then fso.CreateFolder(source) - ' If target already exists, delete it (whether folder or junction) - If fso.FolderExists(target) Then - fso.DeleteFolder target, True - WScript.Echo "Removed existing target: " & target - End If - WScript.Echo "Creating junction: " & target & " -> " & source - ' Use cmd /c mklink /J (target must not exist before) - cmd = "cmd /c mklink /J """ & target & """ """ & source & """" - shell.Run cmd, 0, True - ' Junction creation doesn't set exit code easily; assume success if target now exists - If fso.FolderExists(target) Then - WScript.Echo "Junction created successfully." - Else - WScript.Echo "Failed to create junction. Ensure source exists and you're on the same drive." - End If -End Sub - ' --- Download function with SSL fallback --- Function DownloadFile(url, destPath) On Error Resume Next @@ -131,10 +110,6 @@ If Not fso.FileExists(exePath) Then WScript.Quit 1 End If -' --- Create junctions for mods and resourcepacks (force recreation) --- -CreateJunction fso.BuildPath(rootDir, "mods"), fso.BuildPath(baseDir, "mods") -CreateJunction fso.BuildPath(rootDir, "resourcepacks"), fso.BuildPath(baseDir, "resourcepacks") - ' --- Build command line --- jvmArgs = Replace(jvmOpts, " ", ",") arguments = "--main-dir . start --join-server " & serverIp & " --jvm-arg=" & jvmArgs & " fabric: -u " & username diff --git a/scripts/PortableMCLoader.cs b/scripts/PortableMCLoader.cs index e85a561..e20e0a9 100644 --- a/scripts/PortableMCLoader.cs +++ b/scripts/PortableMCLoader.cs @@ -8,7 +8,7 @@ class Program { static void Main(string[] args) { - // Force TLS 1.2 to avoid SSL issues + // Force TLS 1.2 ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; if (args.Length < 3) @@ -23,7 +23,6 @@ static void Main(string[] args) string baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PortableMC"); Directory.CreateDirectory(baseDir); - string binDir = Path.Combine(baseDir, "portablemc_bin"); string exePath = Path.Combine(binDir, "portablemc.exe"); @@ -45,7 +44,7 @@ static void Main(string[] args) } catch (Exception ex) { - Console.WriteLine("Extraction failed: " + ex.Message); + Console.WriteLine(string.Format("Extraction failed: {0}", ex.Message)); Environment.Exit(1); } finally @@ -53,12 +52,10 @@ static void Main(string[] args) File.Delete(zipPath); } - // Flatten subdirectories FlattenDirectory(binDir); Console.WriteLine("Extraction complete."); } - // Build command line string jvmArgs = jvmOpts.Replace(' ', ','); string arguments = string.Format("--main-dir . start --join-server {0} --jvm-arg={1} fabric: -u {2}", serverIp, jvmArgs, username); @@ -66,10 +63,8 @@ static void Main(string[] args) Console.WriteLine(string.Format("Launching: {0} {1}", exePath, arguments)); Console.WriteLine(string.Format("Working directory: {0}", baseDir)); - // Set environment variable to avoid elevation prompts Environment.SetEnvironmentVariable("__COMPAT_LAYER", "RUNASINVOKER"); - // Start Minecraft with hidden console ProcessStartInfo psi = new ProcessStartInfo { FileName = exePath, @@ -103,6 +98,10 @@ static void Main(string[] args) static bool DownloadFile(string url, string destPath) { + // Manual check for environment variable (C# 5 compatible) + string envVar = Environment.GetEnvironmentVariable("ALLOW_INSECURE_SSL"); + bool allowInsecure = envVar != null && envVar.ToLower() == "true"; + try { Console.WriteLine(string.Format("Downloading {0} -> {1}", url, destPath)); @@ -115,19 +114,27 @@ static bool DownloadFile(string url, string destPath) catch (Exception ex) { Console.WriteLine(string.Format("First download attempt failed: {0}", ex.Message)); - Console.WriteLine("Retrying with SSL verification disabled..."); - try + if (allowInsecure) { - ServicePointManager.ServerCertificateValidationCallback = (sender, cert, chain, errors) => true; - using (WebClient client = new WebClient()) + Console.WriteLine("Retrying with SSL verification disabled..."); + try { - client.DownloadFile(url, destPath); + ServicePointManager.ServerCertificateValidationCallback = (sender, cert, chain, errors) => true; + using (WebClient client = new WebClient()) + { + client.DownloadFile(url, destPath); + } + return true; + } + catch (Exception ex2) + { + Console.WriteLine(string.Format("Download failed even with SSL disabled: {0}", ex2.Message)); + return false; } - return true; } - catch (Exception ex2) + else { - Console.WriteLine(string.Format("Download failed even with SSL disabled: {0}", ex2.Message)); + Console.WriteLine("SSL verification failed and insecure SSL is disabled."); return false; } } diff --git a/scripts/portablemc.py b/scripts/portablemc.py index ed1915f..1518607 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -23,14 +23,12 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") - def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace("&", "&").replace("<", "<").replace(">", ">") - + return s.replace('&', '&').replace('<', '<').replace('>', '>') # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") +VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -55,7 +53,6 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil - PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -68,7 +65,6 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter - ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -924,38 +920,34 @@ def escape_html(s): """ - # --- ROUTES --- -@socketio.on("connect") +@socketio.on('connect') def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f"Client connected: {client_id} (Total: {connected_clients})") - emit("status", {"core": "online", "minecraft": "checking"}) - + print(f'Client connected: {client_id} (Total: {connected_clients})') + emit('status', {'core': 'online', 'minecraft': 'checking'}) -@socketio.on("disconnect") +@socketio.on('disconnect') def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f"Client disconnected: {client_id} (Total: {connected_clients})") + print(f'Client disconnected: {client_id} (Total: {connected_clients})') - -@socketio.on("ping_minecraft") +@socketio.on('ping_minecraft') def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit("minecraft_status", {"online": True}) + emit('minecraft_status', {'online': True}) except Exception: - emit("minecraft_status", {"online": False}) - + emit('minecraft_status', {'online': False}) @app.route("/ping") def ping(): @@ -968,7 +960,6 @@ def ping(): except Exception: return jsonify(online=False), 200 - @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -978,17 +969,15 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): - def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -997,44 +986,31 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: - def error_gen(): - payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): - def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: - def error_gen(): - payload = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1043,15 +1019,13 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m", + "\x1b[90mPlease close the game before launching again.\x1b[0m" ] - def error_gen(): for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1066,9 +1040,7 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get( - "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") - ) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1124,15 +1096,13 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} - ) + busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == "nt": + if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1148,7 +1118,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo, + startupinfo=startupinfo ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1160,12 +1130,7 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", - } - ) + disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1178,7 +1143,7 @@ def generate(): break continue - raw_line = line.rstrip("\n") + raw_line = line.rstrip('\n') now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1189,23 +1154,18 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert( - safe_line, full=False - ).strip() - payload = json.dumps({"type": "html", "content": html_line}) + html_line = ansi_converter.convert(safe_line, full=False).strip() + payload = json.dumps({'type': 'html', 'content': html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) else: - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if ( - current_file != last_progress - and (now - last_send_time) > update_interval - ): + if current_file != last_progress and (now - last_send_time) > update_interval: yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1219,21 +1179,14 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps( - { - "type": "ansi", - "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps( - {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} - ) + payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) yield f"data: {payload}\n\n" except Exception: pass @@ -1248,18 +1201,8 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", - } - ) - tip_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", - } - ) + ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) + tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1272,12 +1215,10 @@ def generate(): response.call_on_close(closed_event.set) return response - @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) - def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1304,7 +1245,6 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") - def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1312,11 +1252,10 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) - signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) + graceful_shutdown(None, None) \ No newline at end of file From 6435f1f7b00f5cc40c01f17f8cf464d4bc6228b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Mar 2026 21:19:05 +0000 Subject: [PATCH 30/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 214 ++++++++++++++++++++++++++++++------------ scripts/portablemc.py | 129 ++++++++++++++++++------- 2 files changed, 247 insertions(+), 96 deletions(-) diff --git a/main.py b/main.py index 8dc56f5..7814689 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != 'windows': +if platform.system().lower() != "windows": print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -33,9 +33,13 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( + "1", + "true", + "yes", +) # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -59,35 +63,36 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - 'x86_64': 'x86_64', - 'amd64': 'x86_64', - 'i686': 'i686', - 'i386': 'i686', - 'aarch64': 'aarch64', - 'arm64': 'aarch64', - 'armv7l': 'arm-gnueabihf', - 'arm': 'arm-gnueabihf', + "x86_64": "x86_64", + "amd64": "x86_64", + "i686": "i686", + "i386": "i686", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "arm-gnueabihf", + "arm": "arm-gnueabihf", } OS_MAP = { - 'windows': 'windows', - 'linux': 'linux', - 'darwin': 'macos', + "windows": "windows", + "linux": "linux", + "darwin": "macos", } + def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, 'x86_64') - if os_name == 'macos': - arch = 'aarch64' if arch == 'aarch64' else 'x86_64' - if os_name == 'linux' and arch not in ('arm-gnueabihf',): - arch += '-gnu' + arch = ARCH_MAP.get(MACHINE, "x86_64") + if os_name == "macos": + arch = "aarch64" if arch == "aarch64" else "x86_64" + if os_name == "linux" and arch not in ("arm-gnueabihf",): + arch += "-gnu" base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == 'windows': + if os_name == "windows": ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -95,6 +100,7 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename + # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -126,6 +132,7 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -135,6 +142,7 @@ def is_junction(path): except OSError: return False + def create_junction(source, target): """ Create a junction from source to target. @@ -163,15 +171,20 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True + check=True, + capture_output=True, + text=True, ) print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" base_dir = BASE_DIR @@ -180,14 +193,18 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") + print( + "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." + ) return ssl._create_unverified_context() return None + # --- Helper functions --- def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" @@ -207,7 +224,7 @@ def ensure_embedded_python(): try: context = get_ssl_context() with urllib.request.urlopen(PYTHON_URL, context=context) as response: - with open(zip_path, 'wb') as out_file: + with open(zip_path, "wb") as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download Python even with unverified SSL: {e2}") @@ -223,6 +240,7 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True + def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -241,11 +259,16 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True + def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [str(EMBEDDED_PYTHON), "--version"], + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -259,6 +282,7 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False + def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -270,7 +294,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, 'wb') as out_file: + with open(pip_script, "wb") as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -278,6 +302,7 @@ def download_get_pip(): return None return pip_script + def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -295,6 +320,7 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True + def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -306,9 +332,12 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(python_exe), str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org"] + cmd = [ + str(python_exe), + str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org", + ] result = subprocess.run(cmd, env=env, capture_output=True, text=True) if result.returncode != 0: print("❌ Failed to install pip.") @@ -317,20 +346,28 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True + def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", "--upgrade", "pip"], + isolated=True, + python_exe=python_exe, + ): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe + ): print(f"❌ Failed to install {pkg}.") return False return True + def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -338,8 +375,10 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, ) path = result.stdout.strip() if path and Path(path).exists(): @@ -348,6 +387,7 @@ def get_certifi_path(python_exe=None): pass return None + def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -365,7 +405,7 @@ def download_portablemc_binary(): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(archive_path, 'wb') as out_file: + with open(archive_path, "wb") as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download even with unverified SSL: {e2}") @@ -396,6 +436,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True + def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -407,8 +448,13 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run([str(binary_path), "--help"], env=env, - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [str(binary_path), "--help"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -431,6 +477,7 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None + def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -442,17 +489,20 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): + if run_pip_command( + ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe + ): method = test_portablemc(python_exe) if method: return method return None + # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -493,12 +543,20 @@ def add_candidate(p): # 4. Common install locations (static paths) for ver in PYTHON_VERSIONS: - num = ver.replace('.', '') + num = ver.replace(".", "") # System-wide installations - for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): + for base in ( + r"C:\Python{}", + r"C:\Program Files\Python{}", + r"C:\Program Files (x86)\Python{}", + ): add_candidate(base.format(num) + "\\python.exe") # User installations - user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" + user_dir = ( + Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + / "Programs" + / f"Python{num}" + ) add_candidate(user_dir / "python.exe") # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") @@ -508,7 +566,9 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) + result = subprocess.run( + [str(p), "--version"], capture_output=True, text=True, timeout=2 + ) if result.returncode == 0 and "Python 3" in result.stdout: version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" valid.append((version_str, p)) @@ -524,6 +584,7 @@ def add_candidate(p): print(f"Selected system Python: {best}") return best + # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -565,6 +626,7 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True + def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -582,8 +644,12 @@ def run_web_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True) + pip_check = subprocess.run( + [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, + capture_output=True, + text=True, + ) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -639,6 +705,7 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) + def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -660,7 +727,7 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}" + f"/p:JvmOpts={DEFAULT_JVM_OPTS}", ] print(f"Executing: {' '.join(cmd)}") try: @@ -672,6 +739,7 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True + def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -688,8 +756,12 @@ def run_cli_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True) + pip_check = subprocess.run( + [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, + capture_output=True, + text=True, + ) if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -698,7 +770,11 @@ def run_cli_launcher(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") print("📦 Installing portablemc via pip...") - if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not run_pip_command( + ["-m", "pip", "install", "portablemc"], + isolated=True, + python_exe=EMBEDDED_PYTHON, + ): print("❌ Failed to install portablemc.") return False @@ -711,19 +787,25 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), "-m", "portablemc", - "--main-dir", ".", + str(EMBEDDED_PYTHON), + "-m", + "portablemc", + "--main-dir", + ".", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness + env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness print(f"🚀 Launching: {' '.join(cmd)}") try: subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) @@ -767,13 +849,19 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(sys_python), "-m", "portablemc", - "--main-dir", ".", + str(sys_python), + "-m", + "portablemc", + "--main-dir", + ".", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -787,6 +875,7 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True + def main(): # Display menu print("\n" + "=" * 60) @@ -813,5 +902,6 @@ def main(): sys.exit(0 if success else 1) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 1518607..ed1915f 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -23,12 +23,14 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") + def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') + return s.replace("&", "&").replace("<", "<").replace(">", ">") + # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -53,6 +55,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -65,6 +68,7 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -920,34 +924,38 @@ def escape_html(s): """ + # --- ROUTES --- -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) + print(f"Client connected: {client_id} (Total: {connected_clients})") + emit("status", {"core": "online", "minecraft": "checking"}) + -@socketio.on('disconnect') +@socketio.on("disconnect") def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') + print(f"Client disconnected: {client_id} (Total: {connected_clients})") -@socketio.on('ping_minecraft') + +@socketio.on("ping_minecraft") def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit('minecraft_status', {'online': True}) + emit("minecraft_status", {"online": True}) except Exception: - emit('minecraft_status', {'online': False}) + emit("minecraft_status", {"online": False}) + @app.route("/ping") def ping(): @@ -960,6 +968,7 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -969,15 +978,17 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): + def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -986,31 +997,44 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): + def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", + } + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1019,13 +1043,15 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" + "\x1b[90mPlease close the game before launching again.\x1b[0m", ] + def error_gen(): for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1040,7 +1066,9 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + local_appdata = os.environ.get( + "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") + ) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1096,13 +1124,15 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + busy_payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} + ) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == 'nt': + if os.name == "nt": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1118,7 +1148,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo + startupinfo=startupinfo, ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1130,7 +1160,12 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + disc_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", + } + ) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1143,7 +1178,7 @@ def generate(): break continue - raw_line = line.rstrip('\n') + raw_line = line.rstrip("\n") now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1154,18 +1189,23 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) + html_line = ansi_converter.convert( + safe_line, full=False + ).strip() + payload = json.dumps({"type": "html", "content": html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: + if ( + current_file != last_progress + and (now - last_send_time) > update_interval + ): yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1179,14 +1219,21 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", + } + ) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + payload = json.dumps( + {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} + ) yield f"data: {payload}\n\n" except Exception: pass @@ -1201,8 +1248,18 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + ended_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", + } + ) + tip_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", + } + ) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1215,10 +1272,12 @@ def generate(): response.call_on_close(closed_event.set) return response + @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1245,6 +1304,7 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1252,10 +1312,11 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) \ No newline at end of file + graceful_shutdown(None, None) From 105a9412d38a11489ef53b0e4520b53aa28c2263 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:27:05 +0000 Subject: [PATCH 31/70] Update main.yml --- .github/workflows/main.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 45fd59e..aa7de62 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -38,9 +38,12 @@ jobs: - name: Commit and push if changed if: steps.git-check.outputs.changed == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git config --global user.name 'github-actions[bot]' git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }} git add . git commit -m "Apply automatic ruff fixes and formatting [skip ci]" git push @@ -78,5 +81,5 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: '⚠️ **Ruff auto-fix applied**\n\nLinting and formatting changes were automatically applied. Please pull the latest changes or update your branch.' + body: '⚠️ **Ruff auto-fix applied (changes detected)**\n\nLinting and formatting changes were found. Please run `ruff check --fix . && ruff format .` locally and push the changes, or let the push job handle it on the next commit.' }) From acc9f6fdbcc7f5dbfd7ea17f2e16eef6673a9fd2 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:29:47 +0000 Subject: [PATCH 32/70] Add files via upload --- main.py | 240 +++++++++++++----------------------------- scripts/portablemc.py | 129 ++++++----------------- 2 files changed, 109 insertions(+), 260 deletions(-) diff --git a/main.py b/main.py index 7814689..7004110 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != "windows": +if platform.system().lower() != 'windows': print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -33,13 +33,9 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( - "1", - "true", - "yes", -) +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -63,36 +59,35 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - "x86_64": "x86_64", - "amd64": "x86_64", - "i686": "i686", - "i386": "i686", - "aarch64": "aarch64", - "arm64": "aarch64", - "armv7l": "arm-gnueabihf", - "arm": "arm-gnueabihf", + 'x86_64': 'x86_64', + 'amd64': 'x86_64', + 'i686': 'i686', + 'i386': 'i686', + 'aarch64': 'aarch64', + 'arm64': 'aarch64', + 'armv7l': 'arm-gnueabihf', + 'arm': 'arm-gnueabihf', } OS_MAP = { - "windows": "windows", - "linux": "linux", - "darwin": "macos", + 'windows': 'windows', + 'linux': 'linux', + 'darwin': 'macos', } - def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, "x86_64") - if os_name == "macos": - arch = "aarch64" if arch == "aarch64" else "x86_64" - if os_name == "linux" and arch not in ("arm-gnueabihf",): - arch += "-gnu" + arch = ARCH_MAP.get(MACHINE, 'x86_64') + if os_name == 'macos': + arch = 'aarch64' if arch == 'aarch64' else 'x86_64' + if os_name == 'linux' and arch not in ('arm-gnueabihf',): + arch += '-gnu' base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == "windows": + if os_name == 'windows': ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -100,7 +95,6 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename - # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -132,7 +126,6 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") - # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -142,7 +135,6 @@ def is_junction(path): except OSError: return False - def create_junction(source, target): """ Create a junction from source to target. @@ -171,20 +163,15 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, - capture_output=True, - text=True, - ) + check=True, capture_output=True, text=True + ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print( - f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" - ) + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") target_path.mkdir(parents=True, exist_ok=True) return False - def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" base_dir = BASE_DIR @@ -193,18 +180,14 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") - # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print( - "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." - ) + print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") return ssl._create_unverified_context() return None - # --- Helper functions --- def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" @@ -224,7 +207,7 @@ def ensure_embedded_python(): try: context = get_ssl_context() with urllib.request.urlopen(PYTHON_URL, context=context) as response: - with open(zip_path, "wb") as out_file: + with open(zip_path, 'wb') as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download Python even with unverified SSL: {e2}") @@ -240,7 +223,6 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True - def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -259,16 +241,11 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True - def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run( - [str(EMBEDDED_PYTHON), "--version"], - capture_output=True, - text=True, - timeout=5, - ) + result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -282,7 +259,6 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False - def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -294,7 +270,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, "wb") as out_file: + with open(pip_script, 'wb') as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -302,7 +278,6 @@ def download_get_pip(): return None return pip_script - def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -312,7 +287,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -320,7 +295,6 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True - def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -332,13 +306,10 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [ - str(python_exe), - str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org", - ] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) + cmd = [str(python_exe), str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org"] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -346,28 +317,20 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True - def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command( - ["-m", "pip", "install", "--upgrade", "pip"], - isolated=True, - python_exe=python_exe, - ): + if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command( - ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe - ): + if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): print(f"❌ Failed to install {pkg}.") return False return True - def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -375,11 +338,9 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, - text=True, - check=True, - env={"PYTHONNOUSERSITE": "1"}, - ) + capture_output=True, text=True, check=True, + env={"PYTHONNOUSERSITE": "1"} + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -387,7 +348,6 @@ def get_certifi_path(python_exe=None): pass return None - def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -405,7 +365,7 @@ def download_portablemc_binary(): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(archive_path, "wb") as out_file: + with open(archive_path, 'wb') as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download even with unverified SSL: {e2}") @@ -436,7 +396,6 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True - def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -448,13 +407,8 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run( - [str(binary_path), "--help"], - env=env, - capture_output=True, - text=True, - timeout=5, - ) + result = subprocess.run([str(binary_path), "--help"], env=env, + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -469,7 +423,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -477,7 +431,6 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None - def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -489,20 +442,17 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command( - ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe - ): + if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): method = test_portablemc(python_exe) if method: return method return None - # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -543,20 +493,12 @@ def add_candidate(p): # 4. Common install locations (static paths) for ver in PYTHON_VERSIONS: - num = ver.replace(".", "") + num = ver.replace('.', '') # System-wide installations - for base in ( - r"C:\Python{}", - r"C:\Program Files\Python{}", - r"C:\Program Files (x86)\Python{}", - ): + for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): add_candidate(base.format(num) + "\\python.exe") # User installations - user_dir = ( - Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) - / "Programs" - / f"Python{num}" - ) + user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" add_candidate(user_dir / "python.exe") # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") @@ -566,9 +508,7 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run( - [str(p), "--version"], capture_output=True, text=True, timeout=2 - ) + result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec if result.returncode == 0 and "Python 3" in result.stdout: version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" valid.append((version_str, p)) @@ -579,12 +519,11 @@ def add_candidate(p): return None # Sort by version descending (higher version first) - valid.sort(key=lambda x: x[0], reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best - # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -600,7 +539,7 @@ def launch_launcher(method, python_exe=None): # Ensure paths for binaries (portablemc binary may be in PORTABLEMC_BIN_DIR) paths = [str(EMBEDDED_DIR), str(EMBEDDED_DIR / "Scripts"), str(PORTABLEMC_BIN_DIR)] env["PATH"] = os.pathsep.join(paths) + os.pathsep + env.get("PATH", "") - env["__compat_layer"] = "runasinvoker" + env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) # For embedded Python, set PYTHONHOME; for system Python, leave it unset if python_exe == EMBEDDED_PYTHON: @@ -618,7 +557,7 @@ def launch_launcher(method, python_exe=None): cmd = [str(python_exe), str(launcher_script)] print(f"🚀 Launching: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, check=True) + subprocess.run(cmd, env=env, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ Launcher exited with error: {e}") return False @@ -626,7 +565,6 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True - def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -644,12 +582,8 @@ def run_web_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run( - [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) + pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, capture_output=True, text=True) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -689,7 +623,7 @@ def run_web_launcher(): for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install {pkg}: {result.stderr}") return False @@ -705,7 +639,6 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) - def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -727,11 +660,11 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}", + f"/p:JvmOpts={DEFAULT_JVM_OPTS}" ] print(f"Executing: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, check=True) + subprocess.run(cmd, env=env, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ MSBuild failed with exit code {e.returncode}") return False @@ -739,7 +672,6 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True - def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -756,12 +688,8 @@ def run_cli_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run( - [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) + pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, capture_output=True, text=True) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -770,11 +698,7 @@ def run_cli_launcher(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") print("📦 Installing portablemc via pip...") - if not run_pip_command( - ["-m", "pip", "install", "portablemc"], - isolated=True, - python_exe=EMBEDDED_PYTHON, - ): + if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): print("❌ Failed to install portablemc.") return False @@ -787,28 +711,22 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), - "-m", - "portablemc", - "--main-dir", - ".", + str(EMBEDDED_PYTHON), "-m", "portablemc", + "--main-dir", ".", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness + env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness print(f"🚀 Launching: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) + subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ CLI launcher exited with error: {e}") return False @@ -834,7 +752,7 @@ def run_cli_launcher(): # Install portablemc with system Python print("📦 Installing portablemc with system Python...") cmd = [str(sys_python), "-m", "pip", "install", "--user", "portablemc"] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install portablemc: {result.stderr}") return False @@ -849,25 +767,19 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(sys_python), - "-m", - "portablemc", - "--main-dir", - ".", + str(sys_python), "-m", "portablemc", + "--main-dir", ".", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) print(f"🚀 Launching: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) + subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ CLI launcher exited with error: {e}") return False @@ -875,7 +787,6 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True - def main(): # Display menu print("\n" + "=" * 60) @@ -902,6 +813,5 @@ def main(): sys.exit(0 if success else 1) - if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/scripts/portablemc.py b/scripts/portablemc.py index ed1915f..1518607 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -23,14 +23,12 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") - def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace("&", "&").replace("<", "<").replace(">", ">") - + return s.replace('&', '&').replace('<', '<').replace('>', '>') # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") +VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -55,7 +53,6 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil - PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -68,7 +65,6 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter - ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -924,38 +920,34 @@ def escape_html(s): """ - # --- ROUTES --- -@socketio.on("connect") +@socketio.on('connect') def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f"Client connected: {client_id} (Total: {connected_clients})") - emit("status", {"core": "online", "minecraft": "checking"}) - + print(f'Client connected: {client_id} (Total: {connected_clients})') + emit('status', {'core': 'online', 'minecraft': 'checking'}) -@socketio.on("disconnect") +@socketio.on('disconnect') def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f"Client disconnected: {client_id} (Total: {connected_clients})") + print(f'Client disconnected: {client_id} (Total: {connected_clients})') - -@socketio.on("ping_minecraft") +@socketio.on('ping_minecraft') def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit("minecraft_status", {"online": True}) + emit('minecraft_status', {'online': True}) except Exception: - emit("minecraft_status", {"online": False}) - + emit('minecraft_status', {'online': False}) @app.route("/ping") def ping(): @@ -968,7 +960,6 @@ def ping(): except Exception: return jsonify(online=False), 200 - @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -978,17 +969,15 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): - def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -997,44 +986,31 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: - def error_gen(): - payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): - def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: - def error_gen(): - payload = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1043,15 +1019,13 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m", + "\x1b[90mPlease close the game before launching again.\x1b[0m" ] - def error_gen(): for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1066,9 +1040,7 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get( - "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") - ) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1124,15 +1096,13 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} - ) + busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == "nt": + if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1148,7 +1118,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo, + startupinfo=startupinfo ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1160,12 +1130,7 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", - } - ) + disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1178,7 +1143,7 @@ def generate(): break continue - raw_line = line.rstrip("\n") + raw_line = line.rstrip('\n') now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1189,23 +1154,18 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert( - safe_line, full=False - ).strip() - payload = json.dumps({"type": "html", "content": html_line}) + html_line = ansi_converter.convert(safe_line, full=False).strip() + payload = json.dumps({'type': 'html', 'content': html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) else: - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if ( - current_file != last_progress - and (now - last_send_time) > update_interval - ): + if current_file != last_progress and (now - last_send_time) > update_interval: yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1219,21 +1179,14 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps( - { - "type": "ansi", - "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps( - {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} - ) + payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) yield f"data: {payload}\n\n" except Exception: pass @@ -1248,18 +1201,8 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", - } - ) - tip_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", - } - ) + ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) + tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1272,12 +1215,10 @@ def generate(): response.call_on_close(closed_event.set) return response - @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) - def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1304,7 +1245,6 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") - def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1312,11 +1252,10 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) - signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) + graceful_shutdown(None, None) \ No newline at end of file From ed4799c80f952a196fee9a0ea0e77dc1ce9669f9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Mar 2026 21:29:56 +0000 Subject: [PATCH 33/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 238 +++++++++++++++++++++++++++++------------- scripts/portablemc.py | 129 +++++++++++++++++------ 2 files changed, 259 insertions(+), 108 deletions(-) diff --git a/main.py b/main.py index 7004110..4d90b88 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != 'windows': +if platform.system().lower() != "windows": print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -33,9 +33,13 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( + "1", + "true", + "yes", +) # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -59,35 +63,36 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - 'x86_64': 'x86_64', - 'amd64': 'x86_64', - 'i686': 'i686', - 'i386': 'i686', - 'aarch64': 'aarch64', - 'arm64': 'aarch64', - 'armv7l': 'arm-gnueabihf', - 'arm': 'arm-gnueabihf', + "x86_64": "x86_64", + "amd64": "x86_64", + "i686": "i686", + "i386": "i686", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "arm-gnueabihf", + "arm": "arm-gnueabihf", } OS_MAP = { - 'windows': 'windows', - 'linux': 'linux', - 'darwin': 'macos', + "windows": "windows", + "linux": "linux", + "darwin": "macos", } + def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, 'x86_64') - if os_name == 'macos': - arch = 'aarch64' if arch == 'aarch64' else 'x86_64' - if os_name == 'linux' and arch not in ('arm-gnueabihf',): - arch += '-gnu' + arch = ARCH_MAP.get(MACHINE, "x86_64") + if os_name == "macos": + arch = "aarch64" if arch == "aarch64" else "x86_64" + if os_name == "linux" and arch not in ("arm-gnueabihf",): + arch += "-gnu" base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == 'windows': + if os_name == "windows": ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -95,6 +100,7 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename + # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -126,6 +132,7 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -135,6 +142,7 @@ def is_junction(path): except OSError: return False + def create_junction(source, target): """ Create a junction from source to target. @@ -163,15 +171,20 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True - ) # nosec + check=True, + capture_output=True, + text=True, + ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" base_dir = BASE_DIR @@ -180,14 +193,18 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") + print( + "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." + ) return ssl._create_unverified_context() return None + # --- Helper functions --- def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" @@ -207,7 +224,7 @@ def ensure_embedded_python(): try: context = get_ssl_context() with urllib.request.urlopen(PYTHON_URL, context=context) as response: - with open(zip_path, 'wb') as out_file: + with open(zip_path, "wb") as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download Python even with unverified SSL: {e2}") @@ -223,6 +240,7 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True + def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -241,11 +259,16 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True + def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(EMBEDDED_PYTHON), "--version"], + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -259,6 +282,7 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False + def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -270,7 +294,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, 'wb') as out_file: + with open(pip_script, "wb") as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -278,6 +302,7 @@ def download_get_pip(): return None return pip_script + def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -287,7 +312,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -295,6 +320,7 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True + def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -306,10 +332,13 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(python_exe), str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org"] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [ + str(python_exe), + str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org", + ] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -317,20 +346,28 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True + def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", "--upgrade", "pip"], + isolated=True, + python_exe=python_exe, + ): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe + ): print(f"❌ Failed to install {pkg}.") return False return True + def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -338,9 +375,11 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} - ) # nosec + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -348,6 +387,7 @@ def get_certifi_path(python_exe=None): pass return None + def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -365,7 +405,7 @@ def download_portablemc_binary(): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(archive_path, 'wb') as out_file: + with open(archive_path, "wb") as out_file: out_file.write(response.read()) except Exception as e2: print(f"❌ Failed to download even with unverified SSL: {e2}") @@ -396,6 +436,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True + def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -407,8 +448,13 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run([str(binary_path), "--help"], env=env, - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(binary_path), "--help"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -423,7 +469,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -431,6 +477,7 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None + def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -442,17 +489,20 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): + if run_pip_command( + ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe + ): method = test_portablemc(python_exe) if method: return method return None + # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -493,12 +543,20 @@ def add_candidate(p): # 4. Common install locations (static paths) for ver in PYTHON_VERSIONS: - num = ver.replace('.', '') + num = ver.replace(".", "") # System-wide installations - for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): + for base in ( + r"C:\Python{}", + r"C:\Program Files\Python{}", + r"C:\Program Files (x86)\Python{}", + ): add_candidate(base.format(num) + "\\python.exe") # User installations - user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" + user_dir = ( + Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + / "Programs" + / f"Python{num}" + ) add_candidate(user_dir / "python.exe") # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") @@ -508,7 +566,9 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec + result = subprocess.run( + [str(p), "--version"], capture_output=True, text=True, timeout=2 + ) # nosec if result.returncode == 0 and "Python 3" in result.stdout: version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" valid.append((version_str, p)) @@ -519,11 +579,12 @@ def add_candidate(p): return None # Sort by version descending (higher version first) - valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best + # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -557,7 +618,7 @@ def launch_launcher(method, python_exe=None): cmd = [str(python_exe), str(launcher_script)] print(f"🚀 Launching: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, check=True) # nosec + subprocess.run(cmd, env=env, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ Launcher exited with error: {e}") return False @@ -565,6 +626,7 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True + def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -582,8 +644,12 @@ def run_web_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True) # nosec + pip_check = subprocess.run( + [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, + capture_output=True, + text=True, + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -623,7 +689,7 @@ def run_web_launcher(): for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install {pkg}: {result.stderr}") return False @@ -639,6 +705,7 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) + def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -660,11 +727,11 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}" + f"/p:JvmOpts={DEFAULT_JVM_OPTS}", ] print(f"Executing: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, check=True) # nosec + subprocess.run(cmd, env=env, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ MSBuild failed with exit code {e.returncode}") return False @@ -672,6 +739,7 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True + def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -688,8 +756,12 @@ def run_cli_launcher(): env_check = os.environ.copy() env_check["PYTHONNOUSERSITE"] = "1" env_check["PYTHONPATH"] = "" - pip_check = subprocess.run([str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True) # nosec + pip_check = subprocess.run( + [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, + capture_output=True, + text=True, + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -698,7 +770,11 @@ def run_cli_launcher(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") print("📦 Installing portablemc via pip...") - if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not run_pip_command( + ["-m", "pip", "install", "portablemc"], + isolated=True, + python_exe=EMBEDDED_PYTHON, + ): print("❌ Failed to install portablemc.") return False @@ -711,22 +787,28 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), "-m", "portablemc", - "--main-dir", ".", + str(EMBEDDED_PYTHON), + "-m", + "portablemc", + "--main-dir", + ".", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness + env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness print(f"🚀 Launching: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec + subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ CLI launcher exited with error: {e}") return False @@ -752,7 +834,7 @@ def run_cli_launcher(): # Install portablemc with system Python print("📦 Installing portablemc with system Python...") cmd = [str(sys_python), "-m", "pip", "install", "--user", "portablemc"] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install portablemc: {result.stderr}") return False @@ -767,19 +849,25 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(sys_python), "-m", "portablemc", - "--main-dir", ".", + str(sys_python), + "-m", + "portablemc", + "--main-dir", + ".", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) print(f"🚀 Launching: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec + subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ CLI launcher exited with error: {e}") return False @@ -787,6 +875,7 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True + def main(): # Display menu print("\n" + "=" * 60) @@ -813,5 +902,6 @@ def main(): sys.exit(0 if success else 1) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 1518607..ed1915f 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -23,12 +23,14 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") + def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') + return s.replace("&", "&").replace("<", "<").replace(">", ">") + # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -53,6 +55,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -65,6 +68,7 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -920,34 +924,38 @@ def escape_html(s): """ + # --- ROUTES --- -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) + print(f"Client connected: {client_id} (Total: {connected_clients})") + emit("status", {"core": "online", "minecraft": "checking"}) + -@socketio.on('disconnect') +@socketio.on("disconnect") def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') + print(f"Client disconnected: {client_id} (Total: {connected_clients})") -@socketio.on('ping_minecraft') + +@socketio.on("ping_minecraft") def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit('minecraft_status', {'online': True}) + emit("minecraft_status", {"online": True}) except Exception: - emit('minecraft_status', {'online': False}) + emit("minecraft_status", {"online": False}) + @app.route("/ping") def ping(): @@ -960,6 +968,7 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -969,15 +978,17 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): + def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -986,31 +997,44 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): + def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", + } + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1019,13 +1043,15 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" + "\x1b[90mPlease close the game before launching again.\x1b[0m", ] + def error_gen(): for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1040,7 +1066,9 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + local_appdata = os.environ.get( + "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") + ) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1096,13 +1124,15 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + busy_payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} + ) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == 'nt': + if os.name == "nt": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1118,7 +1148,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo + startupinfo=startupinfo, ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1130,7 +1160,12 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + disc_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", + } + ) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1143,7 +1178,7 @@ def generate(): break continue - raw_line = line.rstrip('\n') + raw_line = line.rstrip("\n") now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1154,18 +1189,23 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) + html_line = ansi_converter.convert( + safe_line, full=False + ).strip() + payload = json.dumps({"type": "html", "content": html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: + if ( + current_file != last_progress + and (now - last_send_time) > update_interval + ): yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1179,14 +1219,21 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", + } + ) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + payload = json.dumps( + {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} + ) yield f"data: {payload}\n\n" except Exception: pass @@ -1201,8 +1248,18 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + ended_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", + } + ) + tip_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", + } + ) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1215,10 +1272,12 @@ def generate(): response.call_on_close(closed_event.set) return response + @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1245,6 +1304,7 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1252,10 +1312,11 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) \ No newline at end of file + graceful_shutdown(None, None) From c1cb18b85504f91d76c3555c2571fd7f3903c8e9 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:35:45 +0000 Subject: [PATCH 34/70] Apply suggestions from code review Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9b506f5..6b718cd 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A Python‑based Minecraft launcher that runs in a browser using [Flask](https:/ Minecraft-Portable-Web/ ├── main.py # Entry point – menu & bootstrapping ├── portablemc.py # Web server (Flask + SocketIO) -├── Scripts/ # Launcher helper scripts +├── scripts/ # Launcher helper scripts │ ├── Launcher.targets # MSBuild task │ ├── PortableMCLoader.cs # C# loader (compiled if needed) │ ├── Launcher.vbs # VBScript fallback @@ -41,7 +41,7 @@ Minecraft-Portable-Web/ ## TL;DR * Test in **Windows Sandbox** * Test on a **Restricted Environment** by **Group Policy** -* Sourcery **Security fixes** +* Apply Sourcery **security fixes** as recommended below ## Getting Started From d2dd5917972088212818a8a0a10216b296007024 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:14:52 +0000 Subject: [PATCH 35/70] Delete requirements.txt No longer needed, main.py does all the work --- requirements.txt | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index add9a33..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -flask -ansi2html -portablemc From 7ed2882c9118d08a0507c7f7183a08ea8928f740 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:19:09 +0000 Subject: [PATCH 36/70] Fix sourcery-ai bugs --- main.py | 447 +++++++++++++++++------------------------- scripts/Launcher.ps1 | 2 +- scripts/Launcher.vbs | 54 +++-- scripts/portablemc.py | 136 ++++--------- 4 files changed, 244 insertions(+), 395 deletions(-) diff --git a/main.py b/main.py index 4d90b88..baac3c4 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != "windows": +if platform.system().lower() != 'windows': print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -33,13 +33,9 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( - "1", - "true", - "yes", -) +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -63,36 +59,35 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - "x86_64": "x86_64", - "amd64": "x86_64", - "i686": "i686", - "i386": "i686", - "aarch64": "aarch64", - "arm64": "aarch64", - "armv7l": "arm-gnueabihf", - "arm": "arm-gnueabihf", + 'x86_64': 'x86_64', + 'amd64': 'x86_64', + 'i686': 'i686', + 'i386': 'i686', + 'aarch64': 'aarch64', + 'arm64': 'aarch64', + 'armv7l': 'arm-gnueabihf', + 'arm': 'arm-gnueabihf', } OS_MAP = { - "windows": "windows", - "linux": "linux", - "darwin": "macos", + 'windows': 'windows', + 'linux': 'linux', + 'darwin': 'macos', } - def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, "x86_64") - if os_name == "macos": - arch = "aarch64" if arch == "aarch64" else "x86_64" - if os_name == "linux" and arch not in ("arm-gnueabihf",): - arch += "-gnu" + arch = ARCH_MAP.get(MACHINE, 'x86_64') + if os_name == 'macos': + arch = 'aarch64' if arch == 'aarch64' else 'x86_64' + if os_name == 'linux' and arch not in ('arm-gnueabihf',): + arch += '-gnu' base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == "windows": + if os_name == 'windows': ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -100,7 +95,6 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename - # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -132,7 +126,6 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") - # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -142,7 +135,6 @@ def is_junction(path): except OSError: return False - def create_junction(source, target): """ Create a junction from source to target. @@ -152,7 +144,14 @@ def create_junction(source, target): source_path = Path(source).resolve() target_path = Path(target).resolve() - # Ensure source exists (create if missing, so junction has a target) + # Prevent self‑junction + if source_path == target_path: + print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") + # Still ensure the directory exists (as a regular folder) + target_path.mkdir(parents=True, exist_ok=True) + return False + + # Ensure source exists source_path.mkdir(parents=True, exist_ok=True) # Remove existing target if it exists @@ -161,7 +160,6 @@ def create_junction(source, target): os.rmdir(str(target_path)) # removes only the junction print(f"Removed existing junction: {target_path}") elif target_path.is_dir(): - # Regular directory – delete contents shutil.rmtree(str(target_path), ignore_errors=True) print(f"Removed existing directory: {target_path}") else: @@ -171,68 +169,68 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, - capture_output=True, - text=True, + check=True, capture_output=True, text=True ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print( - f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" - ) + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") target_path.mkdir(parents=True, exist_ok=True) return False - def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" - base_dir = BASE_DIR + # Compute AppData path directly to avoid any global variable issues + appdata = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + base_dir = appdata / "PortableMC" base_dir.mkdir(parents=True, exist_ok=True) create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") - # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print( - "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." - ) + print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") return ssl._create_unverified_context() return None - -# --- Helper functions --- -def ensure_embedded_python(): - """Download and extract embedded Python to BASE_DIR if missing.""" - BASE_DIR.mkdir(parents=True, exist_ok=True) - if EMBEDDED_PYTHON.exists(): - print("✅ Embedded Python already exists.") - return True - - print("📥 Downloading embedded Python...") - zip_path = BASE_DIR / "python-embed.zip" +def download_file(url, dest_path): + """Download a file with optional insecure fallback.""" try: - urllib.request.urlretrieve(PYTHON_URL, zip_path) + urllib.request.urlretrieve(url, dest_path) + return True except Exception as e: print(f"⚠️ First download attempt failed: {e}") if ALLOW_INSECURE_SSL: print("📥 Retrying with SSL verification disabled...") try: context = get_ssl_context() - with urllib.request.urlopen(PYTHON_URL, context=context) as response: - with open(zip_path, "wb") as out_file: - out_file.write(response.read()) + with urllib.request.urlopen(url, context=context) as response: + with open(dest_path, 'wb') as f: + f.write(response.read()) + return True except Exception as e2: - print(f"❌ Failed to download Python even with unverified SSL: {e2}") + print(f"❌ Download failed even with unverified SSL: {e2}") return False else: print("❌ Download failed and insecure SSL is disabled.") return False +# --- Helper functions --- +def ensure_embedded_python(): + """Download and extract embedded Python to BASE_DIR if missing.""" + BASE_DIR.mkdir(parents=True, exist_ok=True) + if EMBEDDED_PYTHON.exists(): + print("✅ Embedded Python already exists.") + return True + + print("📥 Downloading embedded Python...") + zip_path = BASE_DIR / "python-embed.zip" + if not download_file(PYTHON_URL, zip_path): + return False + print("📦 Extracting...") with zipfile.ZipFile(zip_path, "r") as zip_ref: zip_ref.extractall(EMBEDDED_DIR) @@ -240,7 +238,6 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True - def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -259,16 +256,11 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True - def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run( - [str(EMBEDDED_PYTHON), "--version"], - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -282,6 +274,39 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False +def setup_embedded_python(): + """Ensure embedded Python is downloaded, pth fixed, pip installed.""" + if not ensure_embedded_python(): + return False + if not fix_pth_file(): + print("⚠️ Failed to fix .pth file for embedded Python.") + return False + if not test_embedded_python(): + return False + + # Ensure pip is available + env_check = os.environ.copy() + env_check["PYTHONNOUSERSITE"] = "1" + env_check["PYTHONPATH"] = "" + pip_check = subprocess.run( + [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], + env=env_check, capture_output=True, text=True + ) # nosec + if pip_check.returncode != 0: + print("📦 pip not found, installing...") + if not install_pip(EMBEDDED_PYTHON): + return False + else: + print(f"✅ pip already installed: {pip_check.stdout.strip()}") + return True + +def install_portablemc_via_embedded(): + """Install portablemc in embedded Python and return method.""" + print("📦 Installing portablemc via pip...") + if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): + print("❌ Failed to install portablemc.") + return None + return test_portablemc(EMBEDDED_PYTHON) def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" @@ -294,7 +319,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, "wb") as out_file: + with open(pip_script, 'wb') as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -302,7 +327,6 @@ def download_get_pip(): return None return pip_script - def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -312,7 +336,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -320,7 +344,6 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True - def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -332,13 +355,10 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [ - str(python_exe), - str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org", - ] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [str(python_exe), str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org"] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -346,28 +366,20 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True - def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command( - ["-m", "pip", "install", "--upgrade", "pip"], - isolated=True, - python_exe=python_exe, - ): + if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command( - ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe - ): + if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): print(f"❌ Failed to install {pkg}.") return False return True - def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -375,11 +387,9 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, - text=True, - check=True, - env={"PYTHONNOUSERSITE": "1"}, - ) # nosec + capture_output=True, text=True, check=True, + env={"PYTHONNOUSERSITE": "1"} + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -387,7 +397,6 @@ def get_certifi_path(python_exe=None): pass return None - def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -396,23 +405,8 @@ def download_portablemc_binary(): print(f"📥 Downloading portablemc from {url}...") BASE_DIR.mkdir(parents=True, exist_ok=True) archive_path = BASE_DIR / "portablemc_download" - try: - urllib.request.urlretrieve(url, archive_path) - except Exception as e: - print(f"⚠️ First download attempt failed: {e}") - if ALLOW_INSECURE_SSL: - print("📥 Retrying with SSL verification disabled...") - try: - context = get_ssl_context() - with urllib.request.urlopen(url, context=context) as response: - with open(archive_path, "wb") as out_file: - out_file.write(response.read()) - except Exception as e2: - print(f"❌ Failed to download even with unverified SSL: {e2}") - return False - else: - print("❌ Download failed and insecure SSL is disabled.") - return False + if not download_file(url, archive_path): + return False PORTABLEMC_BIN_DIR.mkdir(exist_ok=True) try: @@ -436,7 +430,6 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True - def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -448,13 +441,8 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run( - [str(binary_path), "--help"], - env=env, - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(binary_path), "--help"], env=env, + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -469,7 +457,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -477,7 +465,6 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None - def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -489,20 +476,17 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command( - ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe - ): + if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): method = test_portablemc(python_exe) if method: return method return None - # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -537,26 +521,18 @@ def add_candidate(p): install_path = winreg.QueryValue(key, f"{ver}\\InstallPath") add_candidate(Path(install_path) / "python.exe") i += 1 - except WindowsError: + except OSError: break winreg.CloseKey(key) # 4. Common install locations (static paths) for ver in PYTHON_VERSIONS: - num = ver.replace(".", "") + num = ver.replace('.', '') # System-wide installations - for base in ( - r"C:\Python{}", - r"C:\Program Files\Python{}", - r"C:\Program Files (x86)\Python{}", - ): + for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): add_candidate(base.format(num) + "\\python.exe") # User installations - user_dir = ( - Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) - / "Programs" - / f"Python{num}" - ) + user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" add_candidate(user_dir / "python.exe") # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") @@ -566,11 +542,12 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run( - [str(p), "--version"], capture_output=True, text=True, timeout=2 - ) # nosec - if result.returncode == 0 and "Python 3" in result.stdout: - version_str = result.stdout.strip().split()[1] # e.g., "3.11.5" + result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec + combined = (result.stdout + result.stderr).strip() + if result.returncode == 0 and "Python 3" in combined: + # Extract version string (e.g., "3.11.5" from "Python 3.11.5") + parts = combined.split() + version_str = parts[1] if len(parts) >= 2 else "unknown" valid.append((version_str, p)) except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): continue @@ -579,12 +556,11 @@ def add_candidate(p): return None # Sort by version descending (higher version first) - valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best - # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -618,7 +594,7 @@ def launch_launcher(method, python_exe=None): cmd = [str(python_exe), str(launcher_script)] print(f"🚀 Launching: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, check=True) # nosec + subprocess.run(cmd, env=env, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ Launcher exited with error: {e}") return False @@ -626,46 +602,21 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True - def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") # Move static folders and game files before launch prepare_user_data() # Try embedded Python - if not ensure_embedded_python(): - print("⚠️ Could not set up embedded Python. Will try system Python.") - else: - if not fix_pth_file(): - print("⚠️ Failed to fix .pth file for embedded Python.") - if test_embedded_python(): - print("✅ Embedded Python is usable.") - # Setup pip and packages with embedded Python - env_check = os.environ.copy() - env_check["PYTHONNOUSERSITE"] = "1" - env_check["PYTHONPATH"] = "" - pip_check = subprocess.run( - [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) # nosec - if pip_check.returncode != 0: - print("📦 pip not found, installing...") - if not install_pip(EMBEDDED_PYTHON): - return False - else: - print(f"✅ pip already installed: {pip_check.stdout.strip()}") - - if not install_base_packages(EMBEDDED_PYTHON): - return False - - method = ensure_portablemc(EMBEDDED_PYTHON) - if not method: - return False - - print("✅ Setup complete. Launching portablemc.py with embedded Python...") - return launch_launcher(method, EMBEDDED_PYTHON) + if setup_embedded_python(): + print("✅ Embedded Python is usable.") + if not install_base_packages(EMBEDDED_PYTHON): + return False + method = ensure_portablemc(EMBEDDED_PYTHON) + if not method: + return False + print("✅ Setup complete. Launching portablemc.py with embedded Python...") + return launch_launcher(method, EMBEDDED_PYTHON) # Embedded Python failed; fall back to system Python print("\n⚠️ Embedded Python not usable. Trying system Python...") @@ -689,7 +640,7 @@ def run_web_launcher(): for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install {pkg}: {result.stderr}") return False @@ -705,7 +656,6 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) - def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -727,11 +677,11 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}", + f"/p:JvmOpts={DEFAULT_JVM_OPTS}" ] print(f"Executing: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, check=True) # nosec + subprocess.run(cmd, env=env, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ MSBuild failed with exit code {e.returncode}") return False @@ -739,84 +689,47 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True - def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") - # Move static folders and game files before launch prepare_user_data() - # Try embedded Python first - if not ensure_embedded_python(): - print("⚠️ Could not set up embedded Python. Will try system Python.") - else: - if not fix_pth_file(): - print("⚠️ Failed to fix .pth file for embedded Python.") - if test_embedded_python(): - # Setup embedded Python (pip + portablemc only) - env_check = os.environ.copy() - env_check["PYTHONNOUSERSITE"] = "1" - env_check["PYTHONPATH"] = "" - pip_check = subprocess.run( - [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) # nosec - if pip_check.returncode != 0: - print("📦 pip not found, installing...") - if not install_pip(EMBEDDED_PYTHON): - return False - else: - print(f"✅ pip already installed: {pip_check.stdout.strip()}") - - print("📦 Installing portablemc via pip...") - if not run_pip_command( - ["-m", "pip", "install", "portablemc"], - isolated=True, - python_exe=EMBEDDED_PYTHON, - ): - print("❌ Failed to install portablemc.") - return False - method = test_portablemc(EMBEDDED_PYTHON) - if not method: - return False - - # Create Junctions for mods and resourcepacks - ensure_junctions() - - # Build CLI arguments (module syntax) - cmd = [ - str(EMBEDDED_PYTHON), - "-m", - "portablemc", - "--main-dir", - ".", - "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, - "fabric:", - "-u", - DEFAULT_USERNAME, - ] - env = os.environ.copy() - env["__COMPAT_LAYER"] = "RUNASINVOKER" - env["PYTHONNOUSERSITE"] = "1" - env["PYTHONPATH"] = "" - env["LAUNCHER_ROOT"] = str(ROOT_DIR) # for completeness - print(f"🚀 Launching: {' '.join(cmd)}") - try: - subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec - except subprocess.CalledProcessError as e: - print(f"❌ CLI launcher exited with error: {e}") - return False - except KeyboardInterrupt: - print("⏹️ Interrupted by user.") + # Try embedded Python first + if setup_embedded_python(): + method = install_portablemc_via_embedded() + if not method: + return False + ensure_junctions() + + # Build CLI arguments (module syntax) + cmd = [ + str(EMBEDDED_PYTHON), "-m", "portablemc", + "--main-dir", ".", + "--output", "human-color", + "start", + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, + "fabric:", + "-u", DEFAULT_USERNAME + ] + env = os.environ.copy() + env["__COMPAT_LAYER"] = "RUNASINVOKER" + env["PYTHONNOUSERSITE"] = "1" + env["PYTHONPATH"] = "" + env["LAUNCHER_ROOT"] = str(ROOT_DIR) + print(f"🚀 Launching: {' '.join(cmd)}") + try: + subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec + return True + except subprocess.CalledProcessError as e: + print(f"❌ CLI launcher exited with error: {e}") + return False + except KeyboardInterrupt: + print("⏹️ Interrupted by user.") return True # Fallback to system Python + print("\n⚠️ Embedded Python not usable. Trying system Python...") sys_python = get_system_python() if not sys_python: print("❌ No system Python found. Cannot proceed.") @@ -844,37 +757,30 @@ def run_cli_launcher(): print("❌ portablemc not available after installation.") return False - # Create Junctions for mods and resourcepacks ensure_junctions() - # Build CLI arguments (module syntax) cmd = [ - str(sys_python), - "-m", - "portablemc", - "--main-dir", - ".", + str(sys_python), "-m", "portablemc", + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) print(f"🚀 Launching: {' '.join(cmd)}") try: subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec + return True except subprocess.CalledProcessError as e: print(f"❌ CLI launcher exited with error: {e}") return False except KeyboardInterrupt: print("⏹️ Interrupted by user.") - return True - + return True def main(): # Display menu @@ -902,6 +808,5 @@ def main(): sys.exit(0 if success else 1) - if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/scripts/Launcher.ps1 b/scripts/Launcher.ps1 index a79b32f..31bde31 100644 --- a/scripts/Launcher.ps1 +++ b/scripts/Launcher.ps1 @@ -90,7 +90,7 @@ if ($process.HasExited) { Remove-Item $tempOut, $tempErr -Force -ErrorAction SilentlyContinue exit $process.ExitCode } else { - Write-Host "Process still running after $timeout seconds detaching." + Write-Host "Process still running after $timeout seconds - detaching." Remove-Item $tempOut, $tempErr -Force -ErrorAction SilentlyContinue exit 0 } \ No newline at end of file diff --git a/scripts/Launcher.vbs b/scripts/Launcher.vbs index 0a4c86e..4139b04 100644 --- a/scripts/Launcher.vbs +++ b/scripts/Launcher.vbs @@ -31,41 +31,39 @@ exePath = fso.BuildPath(binDir, "portablemc.exe") ' --- Download function with SSL fallback --- Function DownloadFile(url, destPath) + Dim allowInsecure + allowInsecure = (UCase(Environment("ALLOW_INSECURE_SSL")) = "TRUE") On Error Resume Next Set http = CreateObject("WinHttp.WinHttpRequest.5.1") http.Open "GET", url, False - http.SetTimeouts 10000, 10000, 10000, 10000 http.Send If Err.Number = 0 And http.Status = 200 Then - Dim binaryData - binaryData = http.ResponseBody - Set stream = CreateObject("ADODB.Stream") - stream.Type = 1 - stream.Open - stream.Write binaryData - stream.SaveToFile destPath, 2 - stream.Close - DownloadFile = True - Exit Function - End If - ' Fallback to XMLHTTP with SSL ignore - Set xmlHttp = CreateObject("MSXML2.ServerXMLHTTP.6.0") - xmlHttp.setOption 2, 13056 - xmlHttp.Open "GET", url, False - xmlHttp.Send - If Err.Number = 0 And xmlHttp.Status = 200 Then - Set stream = CreateObject("ADODB.Stream") - stream.Type = 1 - stream.Open - stream.Write xmlHttp.ResponseBody - stream.SaveToFile destPath, 2 - stream.Close - DownloadFile = True + ' Success – write file Else - WScript.Echo "Download failed: " & Err.Description - DownloadFile = False + If allowInsecure Then + Err.Clear + Set http = CreateObject("MSXML2.ServerXMLHTTP") + http.Open "GET", url, False + http.setOption 2, 13056 ' ignore certificate errors + http.Send + If Err.Number = 0 And http.Status = 200 Then + ' Success with insecure fallback + Else + Exit Function + End If + Else + Exit Function + End If End If - On Error Goto 0 + ' Write the response body to file + Dim stream + Set stream = CreateObject("ADODB.Stream") + stream.Type = 1 + stream.Open + stream.Write http.ResponseBody + stream.SaveToFile destPath, 2 + stream.Close + DownloadFile = True End Function Sub ExtractZip(zipPath, destDir) diff --git a/scripts/portablemc.py b/scripts/portablemc.py index ed1915f..08dcc8c 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -14,6 +14,13 @@ import json import importlib.util +# --- Guard: must be launched through main.py --- +if os.environ.get("LAUNCHER_ROOT") is None: + print("⚠️ This script is designed to be launched through main.py.") + print("⚠️ Please run main.py and select the web launcher option.") + print("⚠️ Direct execution is not supported.") + sys.exit(1) + # Determine base directory (where Minecraft files are stored) APPDATA = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) BASE_DIR = APPDATA / "PortableMC" @@ -23,14 +30,12 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") - def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace("&", "&").replace("<", "<").replace(">", ">") - + return s.replace('&', '&').replace('<', '<').replace('>', '>') # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") +VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -55,7 +60,6 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil - PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -68,7 +72,6 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter - ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -924,38 +927,34 @@ def escape_html(s): """ - # --- ROUTES --- -@socketio.on("connect") +@socketio.on('connect') def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f"Client connected: {client_id} (Total: {connected_clients})") - emit("status", {"core": "online", "minecraft": "checking"}) + print(f'Client connected: {client_id} (Total: {connected_clients})') + emit('status', {'core': 'online', 'minecraft': 'checking'}) - -@socketio.on("disconnect") +@socketio.on('disconnect') def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f"Client disconnected: {client_id} (Total: {connected_clients})") - + print(f'Client disconnected: {client_id} (Total: {connected_clients})') -@socketio.on("ping_minecraft") +@socketio.on('ping_minecraft') def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit("minecraft_status", {"online": True}) + emit('minecraft_status', {'online': True}) except Exception: - emit("minecraft_status", {"online": False}) - + emit('minecraft_status', {'online': False}) @app.route("/ping") def ping(): @@ -968,7 +967,6 @@ def ping(): except Exception: return jsonify(online=False), 200 - @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -978,17 +976,15 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): - def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -997,44 +993,31 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: - def error_gen(): - payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): - def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: - def error_gen(): - payload = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1043,15 +1026,13 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m", + "\x1b[90mPlease close the game before launching again.\x1b[0m" ] - def error_gen(): for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1066,9 +1047,7 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get( - "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") - ) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1124,15 +1103,13 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} - ) + busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == "nt": + if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1148,7 +1125,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo, + startupinfo=startupinfo ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1160,12 +1137,7 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", - } - ) + disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1178,7 +1150,7 @@ def generate(): break continue - raw_line = line.rstrip("\n") + raw_line = line.rstrip('\n') now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1189,23 +1161,18 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert( - safe_line, full=False - ).strip() - payload = json.dumps({"type": "html", "content": html_line}) + html_line = ansi_converter.convert(safe_line, full=False).strip() + payload = json.dumps({'type': 'html', 'content': html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) else: - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if ( - current_file != last_progress - and (now - last_send_time) > update_interval - ): + if current_file != last_progress and (now - last_send_time) > update_interval: yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1219,21 +1186,14 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps( - { - "type": "ansi", - "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps( - {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} - ) + payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) yield f"data: {payload}\n\n" except Exception: pass @@ -1248,18 +1208,8 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", - } - ) - tip_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", - } - ) + ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) + tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1272,12 +1222,10 @@ def generate(): response.call_on_close(closed_event.set) return response - @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) - def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1304,7 +1252,6 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") - def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1312,11 +1259,10 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) - signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) + graceful_shutdown(None, None) \ No newline at end of file From 5455d4837650bb4f2f84c2de5b0086c23dd0067c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Mar 2026 22:19:17 +0000 Subject: [PATCH 37/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 229 +++++++++++++++++++++++++++++------------- scripts/portablemc.py | 129 +++++++++++++++++------- 2 files changed, 255 insertions(+), 103 deletions(-) diff --git a/main.py b/main.py index baac3c4..c1b0573 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != 'windows': +if platform.system().lower() != "windows": print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -33,9 +33,13 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( + "1", + "true", + "yes", +) # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -59,35 +63,36 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - 'x86_64': 'x86_64', - 'amd64': 'x86_64', - 'i686': 'i686', - 'i386': 'i686', - 'aarch64': 'aarch64', - 'arm64': 'aarch64', - 'armv7l': 'arm-gnueabihf', - 'arm': 'arm-gnueabihf', + "x86_64": "x86_64", + "amd64": "x86_64", + "i686": "i686", + "i386": "i686", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "arm-gnueabihf", + "arm": "arm-gnueabihf", } OS_MAP = { - 'windows': 'windows', - 'linux': 'linux', - 'darwin': 'macos', + "windows": "windows", + "linux": "linux", + "darwin": "macos", } + def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, 'x86_64') - if os_name == 'macos': - arch = 'aarch64' if arch == 'aarch64' else 'x86_64' - if os_name == 'linux' and arch not in ('arm-gnueabihf',): - arch += '-gnu' + arch = ARCH_MAP.get(MACHINE, "x86_64") + if os_name == "macos": + arch = "aarch64" if arch == "aarch64" else "x86_64" + if os_name == "linux" and arch not in ("arm-gnueabihf",): + arch += "-gnu" base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == 'windows': + if os_name == "windows": ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -95,6 +100,7 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename + # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -126,6 +132,7 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -135,6 +142,7 @@ def is_junction(path): except OSError: return False + def create_junction(source, target): """ Create a junction from source to target. @@ -146,7 +154,9 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") + print( + f"⚠️ Source and target are the same ({source_path}); skipping junction creation." + ) # Still ensure the directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -169,15 +179,20 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True + check=True, + capture_output=True, + text=True, ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" # Compute AppData path directly to avoid any global variable issues @@ -188,14 +203,18 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") + print( + "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." + ) return ssl._create_unverified_context() return None + def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -208,7 +227,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, 'wb') as f: + with open(dest_path, "wb") as f: f.write(response.read()) return True except Exception as e2: @@ -218,6 +237,7 @@ def download_file(url, dest_path): print("❌ Download failed and insecure SSL is disabled.") return False + # --- Helper functions --- def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" @@ -238,6 +258,7 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True + def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -256,11 +277,16 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True + def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(EMBEDDED_PYTHON), "--version"], + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -274,6 +300,7 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False + def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -290,8 +317,10 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True - ) # nosec + env=env_check, + capture_output=True, + text=True, + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -300,14 +329,20 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True + def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" print("📦 Installing portablemc via pip...") - if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not run_pip_command( + ["-m", "pip", "install", "portablemc"], + isolated=True, + python_exe=EMBEDDED_PYTHON, + ): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) + def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -319,7 +354,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, 'wb') as out_file: + with open(pip_script, "wb") as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -327,6 +362,7 @@ def download_get_pip(): return None return pip_script + def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -336,7 +372,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -344,6 +380,7 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True + def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -355,10 +392,13 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(python_exe), str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org"] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [ + str(python_exe), + str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org", + ] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -366,20 +406,28 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True + def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", "--upgrade", "pip"], + isolated=True, + python_exe=python_exe, + ): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe + ): print(f"❌ Failed to install {pkg}.") return False return True + def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -387,9 +435,11 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} - ) # nosec + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -397,6 +447,7 @@ def get_certifi_path(python_exe=None): pass return None + def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -430,6 +481,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True + def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -441,8 +493,13 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run([str(binary_path), "--help"], env=env, - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(binary_path), "--help"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -457,7 +514,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -465,6 +522,7 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None + def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -476,17 +534,20 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): + if run_pip_command( + ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe + ): method = test_portablemc(python_exe) if method: return method return None + # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -527,12 +588,20 @@ def add_candidate(p): # 4. Common install locations (static paths) for ver in PYTHON_VERSIONS: - num = ver.replace('.', '') + num = ver.replace(".", "") # System-wide installations - for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): + for base in ( + r"C:\Python{}", + r"C:\Program Files\Python{}", + r"C:\Program Files (x86)\Python{}", + ): add_candidate(base.format(num) + "\\python.exe") # User installations - user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" + user_dir = ( + Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + / "Programs" + / f"Python{num}" + ) add_candidate(user_dir / "python.exe") # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") @@ -542,7 +611,9 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec + result = subprocess.run( + [str(p), "--version"], capture_output=True, text=True, timeout=2 + ) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: # Extract version string (e.g., "3.11.5" from "Python 3.11.5") @@ -556,11 +627,12 @@ def add_candidate(p): return None # Sort by version descending (higher version first) - valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best + # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -594,7 +666,7 @@ def launch_launcher(method, python_exe=None): cmd = [str(python_exe), str(launcher_script)] print(f"🚀 Launching: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, check=True) # nosec + subprocess.run(cmd, env=env, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ Launcher exited with error: {e}") return False @@ -602,6 +674,7 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True + def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -640,7 +713,7 @@ def run_web_launcher(): for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install {pkg}: {result.stderr}") return False @@ -656,6 +729,7 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) + def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -677,11 +751,11 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}" + f"/p:JvmOpts={DEFAULT_JVM_OPTS}", ] print(f"Executing: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, check=True) # nosec + subprocess.run(cmd, env=env, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ MSBuild failed with exit code {e.returncode}") return False @@ -689,6 +763,7 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True + def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -703,14 +778,21 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), "-m", "portablemc", - "--main-dir", ".", - "--output", "human-color", + str(EMBEDDED_PYTHON), + "-m", + "portablemc", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" @@ -760,14 +842,21 @@ def run_cli_launcher(): ensure_junctions() cmd = [ - str(sys_python), "-m", "portablemc", - "--main-dir", ".", - "--output", "human-color", + str(sys_python), + "-m", + "portablemc", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -782,6 +871,7 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True + def main(): # Display menu print("\n" + "=" * 60) @@ -808,5 +898,6 @@ def main(): sys.exit(0 if success else 1) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 08dcc8c..28ad8e6 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -30,12 +30,14 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") + def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') + return s.replace("&", "&").replace("<", "<").replace(">", ">") + # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -60,6 +62,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -72,6 +75,7 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -927,34 +931,38 @@ def escape_html(s): """ + # --- ROUTES --- -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) + print(f"Client connected: {client_id} (Total: {connected_clients})") + emit("status", {"core": "online", "minecraft": "checking"}) + -@socketio.on('disconnect') +@socketio.on("disconnect") def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') + print(f"Client disconnected: {client_id} (Total: {connected_clients})") -@socketio.on('ping_minecraft') + +@socketio.on("ping_minecraft") def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit('minecraft_status', {'online': True}) + emit("minecraft_status", {"online": True}) except Exception: - emit('minecraft_status', {'online': False}) + emit("minecraft_status", {"online": False}) + @app.route("/ping") def ping(): @@ -967,6 +975,7 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -976,15 +985,17 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): + def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -993,31 +1004,44 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): + def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", + } + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1026,13 +1050,15 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" + "\x1b[90mPlease close the game before launching again.\x1b[0m", ] + def error_gen(): for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1047,7 +1073,9 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + local_appdata = os.environ.get( + "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") + ) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1103,13 +1131,15 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + busy_payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} + ) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == 'nt': + if os.name == "nt": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1125,7 +1155,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo + startupinfo=startupinfo, ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1137,7 +1167,12 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + disc_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", + } + ) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1150,7 +1185,7 @@ def generate(): break continue - raw_line = line.rstrip('\n') + raw_line = line.rstrip("\n") now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1161,18 +1196,23 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) + html_line = ansi_converter.convert( + safe_line, full=False + ).strip() + payload = json.dumps({"type": "html", "content": html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: + if ( + current_file != last_progress + and (now - last_send_time) > update_interval + ): yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1186,14 +1226,21 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", + } + ) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + payload = json.dumps( + {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} + ) yield f"data: {payload}\n\n" except Exception: pass @@ -1208,8 +1255,18 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + ended_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", + } + ) + tip_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", + } + ) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1222,10 +1279,12 @@ def generate(): response.call_on_close(closed_event.set) return response + @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1252,6 +1311,7 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1259,10 +1319,11 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) \ No newline at end of file + graceful_shutdown(None, None) From ca0a2b6145a9d1e0dc4101fff67f06bd82acb59e Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:27:35 +0000 Subject: [PATCH 38/70] Apply suggestions from code review Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6b718cd..dcefa80 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ All downloaded files (embedded Python, portablemc binary) are stored in `%LOCALA * **Workflow:** The GitHub Actions workflow (`.github/workflows/main.yml`) automatically applies Ruff fixes on push/pull requests. ## Troubleshooting -* `OSError: [WinError 1260]` – Group policy blocks the method you chose. Try another option. +* `OSError: [WinError 1260]` – Group Policy blocks the method you chose. Try another option. * **PowerShell / VBScript errors** – Ensure the scripts have proper permissions. The launcher sets `__COMPAT_LAYER=RUNASINVOKER` to avoid UAC prompts. * **SSL errors** – The C# loader and Python scripts force TLS 1.2 and fall back to disabling certificate validation. * **Junction creation fails** – The launcher falls back to creating regular directories. From a68c5dbf286d9e7f8db127f32ae751ad23d7dc6f Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:41:53 +0000 Subject: [PATCH 39/70] Apply suggestions from code review --- main.py | 229 +++++++++++------------------------- scripts/Launcher.ps1 | 31 +++-- scripts/Launcher.vbs | 64 ++++++---- scripts/PortableMCLoader.cs | 2 +- scripts/portablemc.py | 129 ++++++-------------- 5 files changed, 165 insertions(+), 290 deletions(-) diff --git a/main.py b/main.py index c1b0573..baac3c4 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != "windows": +if platform.system().lower() != 'windows': print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -33,13 +33,9 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( - "1", - "true", - "yes", -) +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -63,36 +59,35 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - "x86_64": "x86_64", - "amd64": "x86_64", - "i686": "i686", - "i386": "i686", - "aarch64": "aarch64", - "arm64": "aarch64", - "armv7l": "arm-gnueabihf", - "arm": "arm-gnueabihf", + 'x86_64': 'x86_64', + 'amd64': 'x86_64', + 'i686': 'i686', + 'i386': 'i686', + 'aarch64': 'aarch64', + 'arm64': 'aarch64', + 'armv7l': 'arm-gnueabihf', + 'arm': 'arm-gnueabihf', } OS_MAP = { - "windows": "windows", - "linux": "linux", - "darwin": "macos", + 'windows': 'windows', + 'linux': 'linux', + 'darwin': 'macos', } - def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, "x86_64") - if os_name == "macos": - arch = "aarch64" if arch == "aarch64" else "x86_64" - if os_name == "linux" and arch not in ("arm-gnueabihf",): - arch += "-gnu" + arch = ARCH_MAP.get(MACHINE, 'x86_64') + if os_name == 'macos': + arch = 'aarch64' if arch == 'aarch64' else 'x86_64' + if os_name == 'linux' and arch not in ('arm-gnueabihf',): + arch += '-gnu' base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == "windows": + if os_name == 'windows': ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -100,7 +95,6 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename - # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -132,7 +126,6 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") - # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -142,7 +135,6 @@ def is_junction(path): except OSError: return False - def create_junction(source, target): """ Create a junction from source to target. @@ -154,9 +146,7 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print( - f"⚠️ Source and target are the same ({source_path}); skipping junction creation." - ) + print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") # Still ensure the directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -179,20 +169,15 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, - capture_output=True, - text=True, + check=True, capture_output=True, text=True ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print( - f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" - ) + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") target_path.mkdir(parents=True, exist_ok=True) return False - def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" # Compute AppData path directly to avoid any global variable issues @@ -203,18 +188,14 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") - # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print( - "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." - ) + print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") return ssl._create_unverified_context() return None - def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -227,7 +208,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, "wb") as f: + with open(dest_path, 'wb') as f: f.write(response.read()) return True except Exception as e2: @@ -237,7 +218,6 @@ def download_file(url, dest_path): print("❌ Download failed and insecure SSL is disabled.") return False - # --- Helper functions --- def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" @@ -258,7 +238,6 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True - def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -277,16 +256,11 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True - def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run( - [str(EMBEDDED_PYTHON), "--version"], - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -300,7 +274,6 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False - def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -317,10 +290,8 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) # nosec + env=env_check, capture_output=True, text=True + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -329,20 +300,14 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True - def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" print("📦 Installing portablemc via pip...") - if not run_pip_command( - ["-m", "pip", "install", "portablemc"], - isolated=True, - python_exe=EMBEDDED_PYTHON, - ): + if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) - def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -354,7 +319,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, "wb") as out_file: + with open(pip_script, 'wb') as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -362,7 +327,6 @@ def download_get_pip(): return None return pip_script - def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -372,7 +336,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -380,7 +344,6 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True - def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -392,13 +355,10 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [ - str(python_exe), - str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org", - ] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [str(python_exe), str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org"] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -406,28 +366,20 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True - def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command( - ["-m", "pip", "install", "--upgrade", "pip"], - isolated=True, - python_exe=python_exe, - ): + if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command( - ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe - ): + if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): print(f"❌ Failed to install {pkg}.") return False return True - def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -435,11 +387,9 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, - text=True, - check=True, - env={"PYTHONNOUSERSITE": "1"}, - ) # nosec + capture_output=True, text=True, check=True, + env={"PYTHONNOUSERSITE": "1"} + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -447,7 +397,6 @@ def get_certifi_path(python_exe=None): pass return None - def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -481,7 +430,6 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True - def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -493,13 +441,8 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run( - [str(binary_path), "--help"], - env=env, - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(binary_path), "--help"], env=env, + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -514,7 +457,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -522,7 +465,6 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None - def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -534,20 +476,17 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command( - ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe - ): + if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): method = test_portablemc(python_exe) if method: return method return None - # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -588,20 +527,12 @@ def add_candidate(p): # 4. Common install locations (static paths) for ver in PYTHON_VERSIONS: - num = ver.replace(".", "") + num = ver.replace('.', '') # System-wide installations - for base in ( - r"C:\Python{}", - r"C:\Program Files\Python{}", - r"C:\Program Files (x86)\Python{}", - ): + for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): add_candidate(base.format(num) + "\\python.exe") # User installations - user_dir = ( - Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) - / "Programs" - / f"Python{num}" - ) + user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" add_candidate(user_dir / "python.exe") # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") @@ -611,9 +542,7 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run( - [str(p), "--version"], capture_output=True, text=True, timeout=2 - ) # nosec + result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: # Extract version string (e.g., "3.11.5" from "Python 3.11.5") @@ -627,12 +556,11 @@ def add_candidate(p): return None # Sort by version descending (higher version first) - valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best - # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -666,7 +594,7 @@ def launch_launcher(method, python_exe=None): cmd = [str(python_exe), str(launcher_script)] print(f"🚀 Launching: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, check=True) # nosec + subprocess.run(cmd, env=env, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ Launcher exited with error: {e}") return False @@ -674,7 +602,6 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True - def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -713,7 +640,7 @@ def run_web_launcher(): for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install {pkg}: {result.stderr}") return False @@ -729,7 +656,6 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) - def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -751,11 +677,11 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}", + f"/p:JvmOpts={DEFAULT_JVM_OPTS}" ] print(f"Executing: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, check=True) # nosec + subprocess.run(cmd, env=env, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ MSBuild failed with exit code {e.returncode}") return False @@ -763,7 +689,6 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True - def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -778,21 +703,14 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), - "-m", - "portablemc", - "--main-dir", - ".", - "--output", - "human-color", + str(EMBEDDED_PYTHON), "-m", "portablemc", + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" @@ -842,21 +760,14 @@ def run_cli_launcher(): ensure_junctions() cmd = [ - str(sys_python), - "-m", - "portablemc", - "--main-dir", - ".", - "--output", - "human-color", + str(sys_python), "-m", "portablemc", + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -871,7 +782,6 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True - def main(): # Display menu print("\n" + "=" * 60) @@ -898,6 +808,5 @@ def main(): sys.exit(0 if success else 1) - if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/scripts/Launcher.ps1 b/scripts/Launcher.ps1 index 31bde31..9705d9a 100644 --- a/scripts/Launcher.ps1 +++ b/scripts/Launcher.ps1 @@ -1,11 +1,12 @@ -$env:__COMPAT_LAYER = "RUNASINVOKER" - param( [string]$Username, [string]$ServerIp, [string]$JvmOpts ) +# Set compatibility layer to avoid UAC prompts +$env:__COMPAT_LAYER = "RUNASINVOKER" + # Fallback to positional arguments if needed if (-not $JvmOpts -and $args.Count -gt 0) { $JvmOpts = $args[0] @@ -31,25 +32,35 @@ if (-not (Test-Path $baseDir)) { New-Item -ItemType Directory -Path $baseDir -Force | Out-Null } -# --- Download portablemc.exe if missing (with SSL fallback) --- +# --- Download portablemc.exe if missing (with optional SSL fallback) --- if (-not (Test-Path $exePath)) { Write-Host "portablemc.exe not found. Downloading to $binDir..." $url = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/portablemc-5.0.2-windows-x86_64-msvc.zip" $zipPath = Join-Path $baseDir "portablemc.zip" + + # Check if insecure SSL is allowed + $allowInsecure = $env:ALLOW_INSECURE_SSL -in @("true", "1", "yes") + try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing } catch { Write-Host "First download attempt failed: $_" - Write-Host "Retrying with SSL verification disabled..." - try { - [System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true} - Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing - } catch { - Write-Host "Download failed. Exiting." + if ($allowInsecure) { + Write-Host "Retrying with SSL verification disabled..." + try { + [System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true} + Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing + } catch { + Write-Host "Download failed even with SSL disabled. Exiting." + exit 1 + } + } else { + Write-Host "SSL verification failed and insecure SSL is disabled. Exiting." exit 1 } } + Write-Host "Extracting..." Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $binDir) @@ -69,8 +80,10 @@ $arguments = "--main-dir . start --join-server $ServerIp --jvm-arg=$jvmArgs fabr Write-Host "Launching: $exePath $arguments" Write-Host "Working directory: $baseDir" +# Ensure the environment variable is set for the child process $env:__COMPAT_LAYER = "RUNASINVOKER" +# Launch Minecraft with output redirection $tempOut = Join-Path $env:TEMP "portablemc_out.txt" $tempErr = Join-Path $env:TEMP "portablemc_err.txt" $process = Start-Process -FilePath $exePath -ArgumentList $arguments -WorkingDirectory $baseDir -NoNewWindow -PassThru -RedirectStandardOutput $tempOut -RedirectStandardError $tempErr diff --git a/scripts/Launcher.vbs b/scripts/Launcher.vbs index 4139b04..7bf6d3d 100644 --- a/scripts/Launcher.vbs +++ b/scripts/Launcher.vbs @@ -31,39 +31,53 @@ exePath = fso.BuildPath(binDir, "portablemc.exe") ' --- Download function with SSL fallback --- Function DownloadFile(url, destPath) - Dim allowInsecure - allowInsecure = (UCase(Environment("ALLOW_INSECURE_SSL")) = "TRUE") + ' Get the environment variable via WScript.Shell + Dim shell, allowInsecure + Set shell = CreateObject("WScript.Shell") + allowInsecure = LCase(shell.Environment("PROCESS")("ALLOW_INSECURE_SSL")) = "true" Or _ + LCase(shell.Environment("PROCESS")("ALLOW_INSECURE_SSL")) = "1" Or _ + LCase(shell.Environment("PROCESS")("ALLOW_INSECURE_SSL")) = "yes" + On Error Resume Next Set http = CreateObject("WinHttp.WinHttpRequest.5.1") http.Open "GET", url, False + http.SetTimeouts 10000, 10000, 10000, 10000 http.Send If Err.Number = 0 And http.Status = 200 Then - ' Success – write file - Else - If allowInsecure Then - Err.Clear - Set http = CreateObject("MSXML2.ServerXMLHTTP") - http.Open "GET", url, False - http.setOption 2, 13056 ' ignore certificate errors - http.Send - If Err.Number = 0 And http.Status = 200 Then - ' Success with insecure fallback - Else - Exit Function - End If + Dim binaryData + binaryData = http.ResponseBody + Set stream = CreateObject("ADODB.Stream") + stream.Type = 1 + stream.Open + stream.Write binaryData + stream.SaveToFile destPath, 2 + stream.Close + DownloadFile = True + Exit Function + End If + + ' Only attempt insecure fallback if allowed + If allowInsecure Then + Err.Clear + Set http = CreateObject("MSXML2.ServerXMLHTTP.6.0") + http.setOption 2, 13056 ' ignore certificate errors + http.Open "GET", url, False + http.Send + If Err.Number = 0 And http.Status = 200 Then + Set stream = CreateObject("ADODB.Stream") + stream.Type = 1 + stream.Open + stream.Write http.ResponseBody + stream.SaveToFile destPath, 2 + stream.Close + DownloadFile = True Else - Exit Function + DownloadFile = False End If + Else + DownloadFile = False End If - ' Write the response body to file - Dim stream - Set stream = CreateObject("ADODB.Stream") - stream.Type = 1 - stream.Open - stream.Write http.ResponseBody - stream.SaveToFile destPath, 2 - stream.Close - DownloadFile = True + On Error Goto 0 End Function Sub ExtractZip(zipPath, destDir) diff --git a/scripts/PortableMCLoader.cs b/scripts/PortableMCLoader.cs index e20e0a9..68ec6df 100644 --- a/scripts/PortableMCLoader.cs +++ b/scripts/PortableMCLoader.cs @@ -100,7 +100,7 @@ static bool DownloadFile(string url, string destPath) { // Manual check for environment variable (C# 5 compatible) string envVar = Environment.GetEnvironmentVariable("ALLOW_INSECURE_SSL"); - bool allowInsecure = envVar != null && envVar.ToLower() == "true"; + bool allowInsecure = envVar != null && (envVar.ToLower() == "true" || envVar == "1" || envVar.ToLower() == "yes"); try { diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 28ad8e6..08dcc8c 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -30,14 +30,12 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") - def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace("&", "&").replace("<", "<").replace(">", ">") - + return s.replace('&', '&').replace('<', '<').replace('>', '>') # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") +VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -62,7 +60,6 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil - PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -75,7 +72,6 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter - ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -931,38 +927,34 @@ def escape_html(s): """ - # --- ROUTES --- -@socketio.on("connect") +@socketio.on('connect') def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f"Client connected: {client_id} (Total: {connected_clients})") - emit("status", {"core": "online", "minecraft": "checking"}) - + print(f'Client connected: {client_id} (Total: {connected_clients})') + emit('status', {'core': 'online', 'minecraft': 'checking'}) -@socketio.on("disconnect") +@socketio.on('disconnect') def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f"Client disconnected: {client_id} (Total: {connected_clients})") + print(f'Client disconnected: {client_id} (Total: {connected_clients})') - -@socketio.on("ping_minecraft") +@socketio.on('ping_minecraft') def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit("minecraft_status", {"online": True}) + emit('minecraft_status', {'online': True}) except Exception: - emit("minecraft_status", {"online": False}) - + emit('minecraft_status', {'online': False}) @app.route("/ping") def ping(): @@ -975,7 +967,6 @@ def ping(): except Exception: return jsonify(online=False), 200 - @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -985,17 +976,15 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): - def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -1004,44 +993,31 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: - def error_gen(): - payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): - def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: - def error_gen(): - payload = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1050,15 +1026,13 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m", + "\x1b[90mPlease close the game before launching again.\x1b[0m" ] - def error_gen(): for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1073,9 +1047,7 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get( - "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") - ) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1131,15 +1103,13 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} - ) + busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == "nt": + if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1155,7 +1125,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo, + startupinfo=startupinfo ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1167,12 +1137,7 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", - } - ) + disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1185,7 +1150,7 @@ def generate(): break continue - raw_line = line.rstrip("\n") + raw_line = line.rstrip('\n') now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1196,23 +1161,18 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert( - safe_line, full=False - ).strip() - payload = json.dumps({"type": "html", "content": html_line}) + html_line = ansi_converter.convert(safe_line, full=False).strip() + payload = json.dumps({'type': 'html', 'content': html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) else: - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if ( - current_file != last_progress - and (now - last_send_time) > update_interval - ): + if current_file != last_progress and (now - last_send_time) > update_interval: yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1226,21 +1186,14 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps( - { - "type": "ansi", - "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps( - {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} - ) + payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) yield f"data: {payload}\n\n" except Exception: pass @@ -1255,18 +1208,8 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", - } - ) - tip_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", - } - ) + ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) + tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1279,12 +1222,10 @@ def generate(): response.call_on_close(closed_event.set) return response - @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) - def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1311,7 +1252,6 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") - def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1319,11 +1259,10 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) - signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) + graceful_shutdown(None, None) \ No newline at end of file From 843fa541a093e58f37c70e660cd2183d45007463 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Mar 2026 22:42:03 +0000 Subject: [PATCH 40/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 229 +++++++++++++++++++++++++++++------------- scripts/portablemc.py | 129 +++++++++++++++++------- 2 files changed, 255 insertions(+), 103 deletions(-) diff --git a/main.py b/main.py index baac3c4..c1b0573 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != 'windows': +if platform.system().lower() != "windows": print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -33,9 +33,13 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( + "1", + "true", + "yes", +) # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -59,35 +63,36 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - 'x86_64': 'x86_64', - 'amd64': 'x86_64', - 'i686': 'i686', - 'i386': 'i686', - 'aarch64': 'aarch64', - 'arm64': 'aarch64', - 'armv7l': 'arm-gnueabihf', - 'arm': 'arm-gnueabihf', + "x86_64": "x86_64", + "amd64": "x86_64", + "i686": "i686", + "i386": "i686", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "arm-gnueabihf", + "arm": "arm-gnueabihf", } OS_MAP = { - 'windows': 'windows', - 'linux': 'linux', - 'darwin': 'macos', + "windows": "windows", + "linux": "linux", + "darwin": "macos", } + def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, 'x86_64') - if os_name == 'macos': - arch = 'aarch64' if arch == 'aarch64' else 'x86_64' - if os_name == 'linux' and arch not in ('arm-gnueabihf',): - arch += '-gnu' + arch = ARCH_MAP.get(MACHINE, "x86_64") + if os_name == "macos": + arch = "aarch64" if arch == "aarch64" else "x86_64" + if os_name == "linux" and arch not in ("arm-gnueabihf",): + arch += "-gnu" base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == 'windows': + if os_name == "windows": ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -95,6 +100,7 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename + # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -126,6 +132,7 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -135,6 +142,7 @@ def is_junction(path): except OSError: return False + def create_junction(source, target): """ Create a junction from source to target. @@ -146,7 +154,9 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") + print( + f"⚠️ Source and target are the same ({source_path}); skipping junction creation." + ) # Still ensure the directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -169,15 +179,20 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True + check=True, + capture_output=True, + text=True, ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" # Compute AppData path directly to avoid any global variable issues @@ -188,14 +203,18 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") + print( + "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." + ) return ssl._create_unverified_context() return None + def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -208,7 +227,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, 'wb') as f: + with open(dest_path, "wb") as f: f.write(response.read()) return True except Exception as e2: @@ -218,6 +237,7 @@ def download_file(url, dest_path): print("❌ Download failed and insecure SSL is disabled.") return False + # --- Helper functions --- def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" @@ -238,6 +258,7 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True + def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -256,11 +277,16 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True + def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(EMBEDDED_PYTHON), "--version"], + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -274,6 +300,7 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False + def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -290,8 +317,10 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True - ) # nosec + env=env_check, + capture_output=True, + text=True, + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -300,14 +329,20 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True + def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" print("📦 Installing portablemc via pip...") - if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not run_pip_command( + ["-m", "pip", "install", "portablemc"], + isolated=True, + python_exe=EMBEDDED_PYTHON, + ): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) + def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -319,7 +354,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, 'wb') as out_file: + with open(pip_script, "wb") as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -327,6 +362,7 @@ def download_get_pip(): return None return pip_script + def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -336,7 +372,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -344,6 +380,7 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True + def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -355,10 +392,13 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(python_exe), str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org"] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [ + str(python_exe), + str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org", + ] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -366,20 +406,28 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True + def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", "--upgrade", "pip"], + isolated=True, + python_exe=python_exe, + ): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe + ): print(f"❌ Failed to install {pkg}.") return False return True + def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -387,9 +435,11 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} - ) # nosec + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -397,6 +447,7 @@ def get_certifi_path(python_exe=None): pass return None + def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -430,6 +481,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True + def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -441,8 +493,13 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run([str(binary_path), "--help"], env=env, - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(binary_path), "--help"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -457,7 +514,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -465,6 +522,7 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None + def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -476,17 +534,20 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): + if run_pip_command( + ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe + ): method = test_portablemc(python_exe) if method: return method return None + # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -527,12 +588,20 @@ def add_candidate(p): # 4. Common install locations (static paths) for ver in PYTHON_VERSIONS: - num = ver.replace('.', '') + num = ver.replace(".", "") # System-wide installations - for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): + for base in ( + r"C:\Python{}", + r"C:\Program Files\Python{}", + r"C:\Program Files (x86)\Python{}", + ): add_candidate(base.format(num) + "\\python.exe") # User installations - user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" + user_dir = ( + Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + / "Programs" + / f"Python{num}" + ) add_candidate(user_dir / "python.exe") # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") @@ -542,7 +611,9 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec + result = subprocess.run( + [str(p), "--version"], capture_output=True, text=True, timeout=2 + ) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: # Extract version string (e.g., "3.11.5" from "Python 3.11.5") @@ -556,11 +627,12 @@ def add_candidate(p): return None # Sort by version descending (higher version first) - valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best + # --- Launcher functions --- def launch_launcher(method, python_exe=None): """Launch portablemc.py with the given Python executable.""" @@ -594,7 +666,7 @@ def launch_launcher(method, python_exe=None): cmd = [str(python_exe), str(launcher_script)] print(f"🚀 Launching: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, check=True) # nosec + subprocess.run(cmd, env=env, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ Launcher exited with error: {e}") return False @@ -602,6 +674,7 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True + def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -640,7 +713,7 @@ def run_web_launcher(): for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install {pkg}: {result.stderr}") return False @@ -656,6 +729,7 @@ def run_web_launcher(): # Launch portablemc.py with system Python, using the same environment return launch_launcher(method, sys_python) + def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") # Move static folders and game files before launch @@ -677,11 +751,11 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}" + f"/p:JvmOpts={DEFAULT_JVM_OPTS}", ] print(f"Executing: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, check=True) # nosec + subprocess.run(cmd, env=env, check=True) # nosec except subprocess.CalledProcessError as e: print(f"❌ MSBuild failed with exit code {e.returncode}") return False @@ -689,6 +763,7 @@ def run_msbuild_launcher(): print("⏹️ Interrupted by user.") return True + def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -703,14 +778,21 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), "-m", "portablemc", - "--main-dir", ".", - "--output", "human-color", + str(EMBEDDED_PYTHON), + "-m", + "portablemc", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" @@ -760,14 +842,21 @@ def run_cli_launcher(): ensure_junctions() cmd = [ - str(sys_python), "-m", "portablemc", - "--main-dir", ".", - "--output", "human-color", + str(sys_python), + "-m", + "portablemc", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -782,6 +871,7 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True + def main(): # Display menu print("\n" + "=" * 60) @@ -808,5 +898,6 @@ def main(): sys.exit(0 if success else 1) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 08dcc8c..28ad8e6 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -30,12 +30,14 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") + def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') + return s.replace("&", "&").replace("<", "<").replace(">", ">") + # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -60,6 +62,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -72,6 +75,7 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -927,34 +931,38 @@ def escape_html(s): """ + # --- ROUTES --- -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) + print(f"Client connected: {client_id} (Total: {connected_clients})") + emit("status", {"core": "online", "minecraft": "checking"}) + -@socketio.on('disconnect') +@socketio.on("disconnect") def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') + print(f"Client disconnected: {client_id} (Total: {connected_clients})") -@socketio.on('ping_minecraft') + +@socketio.on("ping_minecraft") def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit('minecraft_status', {'online': True}) + emit("minecraft_status", {"online": True}) except Exception: - emit('minecraft_status', {'online': False}) + emit("minecraft_status", {"online": False}) + @app.route("/ping") def ping(): @@ -967,6 +975,7 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -976,15 +985,17 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): + def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -993,31 +1004,44 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): + def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", + } + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1026,13 +1050,15 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" + "\x1b[90mPlease close the game before launching again.\x1b[0m", ] + def error_gen(): for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1047,7 +1073,9 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + local_appdata = os.environ.get( + "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") + ) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1103,13 +1131,15 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + busy_payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} + ) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == 'nt': + if os.name == "nt": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1125,7 +1155,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo + startupinfo=startupinfo, ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1137,7 +1167,12 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + disc_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", + } + ) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1150,7 +1185,7 @@ def generate(): break continue - raw_line = line.rstrip('\n') + raw_line = line.rstrip("\n") now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1161,18 +1196,23 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) + html_line = ansi_converter.convert( + safe_line, full=False + ).strip() + payload = json.dumps({"type": "html", "content": html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: + if ( + current_file != last_progress + and (now - last_send_time) > update_interval + ): yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1186,14 +1226,21 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", + } + ) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + payload = json.dumps( + {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} + ) yield f"data: {payload}\n\n" except Exception: pass @@ -1208,8 +1255,18 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + ended_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", + } + ) + tip_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", + } + ) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1222,10 +1279,12 @@ def generate(): response.call_on_close(closed_event.set) return response + @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1252,6 +1311,7 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1259,10 +1319,11 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) \ No newline at end of file + graceful_shutdown(None, None) From dae6a5b3e38c1409015e0aca0b5bfc812b766cb9 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:28:34 +0000 Subject: [PATCH 41/70] Sourcery-ai bug patches --- main.py | 364 +++++++++++++++++++-------------------- scripts/Launcher.targets | 24 ++- scripts/portablemc.py | 137 ++++----------- 3 files changed, 236 insertions(+), 289 deletions(-) diff --git a/main.py b/main.py index c1b0573..36572d2 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != "windows": +if platform.system().lower() != 'windows': print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -33,13 +33,9 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( - "1", - "true", - "yes", -) +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -63,36 +59,35 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - "x86_64": "x86_64", - "amd64": "x86_64", - "i686": "i686", - "i386": "i686", - "aarch64": "aarch64", - "arm64": "aarch64", - "armv7l": "arm-gnueabihf", - "arm": "arm-gnueabihf", + 'x86_64': 'x86_64', + 'amd64': 'x86_64', + 'i686': 'i686', + 'i386': 'i686', + 'aarch64': 'aarch64', + 'arm64': 'aarch64', + 'armv7l': 'arm-gnueabihf', + 'arm': 'arm-gnueabihf', } OS_MAP = { - "windows": "windows", - "linux": "linux", - "darwin": "macos", + 'windows': 'windows', + 'linux': 'linux', + 'darwin': 'macos', } - def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, "x86_64") - if os_name == "macos": - arch = "aarch64" if arch == "aarch64" else "x86_64" - if os_name == "linux" and arch not in ("arm-gnueabihf",): - arch += "-gnu" + arch = ARCH_MAP.get(MACHINE, 'x86_64') + if os_name == 'macos': + arch = 'aarch64' if arch == 'aarch64' else 'x86_64' + if os_name == 'linux' and arch not in ('arm-gnueabihf',): + arch += '-gnu' base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == "windows": + if os_name == 'windows': ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -100,7 +95,6 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename - # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -132,7 +126,6 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") - # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -142,7 +135,6 @@ def is_junction(path): except OSError: return False - def create_junction(source, target): """ Create a junction from source to target. @@ -154,9 +146,7 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print( - f"⚠️ Source and target are the same ({source_path}); skipping junction creation." - ) + print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") # Still ensure the directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -179,20 +169,15 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, - capture_output=True, - text=True, + check=True, capture_output=True, text=True ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print( - f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" - ) + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") target_path.mkdir(parents=True, exist_ok=True) return False - def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" # Compute AppData path directly to avoid any global variable issues @@ -203,18 +188,14 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") - # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print( - "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." - ) + print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") return ssl._create_unverified_context() return None - def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -227,7 +208,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, "wb") as f: + with open(dest_path, 'wb') as f: f.write(response.read()) return True except Exception as e2: @@ -237,8 +218,73 @@ def download_file(url, dest_path): print("❌ Download failed and insecure SSL is disabled.") return False - # --- Helper functions --- +def find_msbuild_candidates(): + """Return a list of candidate MSBuild.exe paths sorted by priority (highest first).""" + candidates = [] + + # 1. vswhere (most reliable, finds the latest Visual Studio) + vswhere = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" + if os.path.isfile(vswhere): + try: + result = subprocess.run([vswhere, "-latest", "-products", "*", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"], + capture_output=True, text=True, timeout=5) + if result.returncode == 0: + for line in result.stdout.strip().split('\n'): + if line and os.path.isfile(line): + candidates.append((line, 100)) # highest priority + except Exception: + pass + + # 2. Hard‑coded candidates with descending priorities + hardcoded = [ + # Visual Studio 2026 (v18.0) + (r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 99), + + # Visual Studio 2022 (v17.0) + (r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 90), + + # Visual Studio 2019 (v16.0) + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 80), + + # Visual Studio 2017 (v15.0) + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", 70), + + # Standalone Build Tools (v14.0, v12.0) + (r"C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe", 60), + (r"C:\Program Files (x86)\MSBuild\12.0\Bin\MSBuild.exe", 50), + + # .NET Framework (64‑bit preferred, then 32‑bit) + (r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe", 40), + (r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe", 30), + ] + + for path, prio in hardcoded: + if os.path.isfile(path): + candidates.append((path, prio)) + + # Remove duplicates (keep highest priority for each path) + unique = {} + for path, prio in candidates: + if path not in unique or prio > unique[path]: + unique[path] = prio + + # Sort by priority descending and return only paths + sorted_candidates = sorted(unique.items(), key=lambda x: x[1], reverse=True) + return [path for path, _ in sorted_candidates] + def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" BASE_DIR.mkdir(parents=True, exist_ok=True) @@ -258,7 +304,6 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True - def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -277,16 +322,11 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True - def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run( - [str(EMBEDDED_PYTHON), "--version"], - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -300,7 +340,6 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False - def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -317,10 +356,8 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) # nosec + env=env_check, capture_output=True, text=True + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -329,20 +366,14 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True - def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" print("📦 Installing portablemc via pip...") - if not run_pip_command( - ["-m", "pip", "install", "portablemc"], - isolated=True, - python_exe=EMBEDDED_PYTHON, - ): + if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) - def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -354,7 +385,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, "wb") as out_file: + with open(pip_script, 'wb') as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -362,7 +393,6 @@ def download_get_pip(): return None return pip_script - def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -372,7 +402,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -380,7 +410,6 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True - def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -392,13 +421,10 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [ - str(python_exe), - str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org", - ] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [str(python_exe), str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org"] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -406,28 +432,20 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True - def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command( - ["-m", "pip", "install", "--upgrade", "pip"], - isolated=True, - python_exe=python_exe, - ): + if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command( - ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe - ): + if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): print(f"❌ Failed to install {pkg}.") return False return True - def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -435,11 +453,9 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, - text=True, - check=True, - env={"PYTHONNOUSERSITE": "1"}, - ) # nosec + capture_output=True, text=True, check=True, + env={"PYTHONNOUSERSITE": "1"} + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -447,7 +463,6 @@ def get_certifi_path(python_exe=None): pass return None - def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -481,7 +496,6 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True - def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -493,13 +507,8 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run( - [str(binary_path), "--help"], - env=env, - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(binary_path), "--help"], env=env, + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -514,7 +523,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -522,7 +531,6 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None - def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -534,20 +542,17 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command( - ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe - ): + if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): method = test_portablemc(python_exe) if method: return method return None - # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -588,20 +593,12 @@ def add_candidate(p): # 4. Common install locations (static paths) for ver in PYTHON_VERSIONS: - num = ver.replace(".", "") + num = ver.replace('.', '') # System-wide installations - for base in ( - r"C:\Python{}", - r"C:\Program Files\Python{}", - r"C:\Program Files (x86)\Python{}", - ): + for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): add_candidate(base.format(num) + "\\python.exe") # User installations - user_dir = ( - Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) - / "Programs" - / f"Python{num}" - ) + user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" add_candidate(user_dir / "python.exe") # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") @@ -611,9 +608,7 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run( - [str(p), "--version"], capture_output=True, text=True, timeout=2 - ) # nosec + result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: # Extract version string (e.g., "3.11.5" from "Python 3.11.5") @@ -627,15 +622,13 @@ def add_candidate(p): return None # Sort by version descending (higher version first) - valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best - # --- Launcher functions --- -def launch_launcher(method, python_exe=None): - """Launch portablemc.py with the given Python executable.""" +def launch_launcher(method, python_exe=None, extra_env=None): launcher_script = PORTABLEMC_PY if not launcher_script.exists(): print("❌ portablemc.py not found in the same directory.") @@ -645,16 +638,22 @@ def launch_launcher(method, python_exe=None): python_exe = EMBEDDED_PYTHON env = os.environ.copy() + if extra_env: + env.update(extra_env) + # Ensure paths for binaries (portablemc binary may be in PORTABLEMC_BIN_DIR) paths = [str(EMBEDDED_DIR), str(EMBEDDED_DIR / "Scripts"), str(PORTABLEMC_BIN_DIR)] env["PATH"] = os.pathsep.join(paths) + os.pathsep + env.get("PATH", "") env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) - # For embedded Python, set PYTHONHOME; for system Python, leave it unset + + # For embedded Python, set PYTHONHOME; for system Python, do not force isolation if python_exe == EMBEDDED_PYTHON: env["PYTHONHOME"] = str(EMBEDDED_DIR) + env["PYTHONNOUSERSITE"] = "1" + # else: extra_env already contains PYTHONUSERBASE and PYTHONNOUSERSITE="0" + env["CLICOLOR_FORCE"] = "1" - env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" env["PORTABLEMC_METHOD"] = method @@ -674,7 +673,6 @@ def launch_launcher(method, python_exe=None): print("⏹️ Interrupted by user.") return True - def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -713,7 +711,7 @@ def run_web_launcher(): for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install {pkg}: {result.stderr}") return False @@ -727,42 +725,50 @@ def run_web_launcher(): print("✅ Setup complete. Launching portablemc.py with system Python...") # Launch portablemc.py with system Python, using the same environment - return launch_launcher(method, sys_python) - + extra_env = { + "PYTHONUSERBASE": str(launcher_python_dir), + "PYTHONNOUSERSITE": "0", + "PATH": os.environ["PATH"] # keep the original PATH + } + return launch_launcher(method, sys_python, extra_env) def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") - # Move static folders and game files before launch prepare_user_data() - if not os.path.isfile(MSBUILD_PATH): - print(f"❌ MSBuild not found at {MSBUILD_PATH}") - return False - if not TARGETS_FILE.exists(): - print(f"❌ Launcher.targets not found at {TARGETS_FILE}") + + candidates = find_msbuild_candidates() + if not candidates: + print("❌ No MSBuild.exe found on the system.") return False - env = os.environ.copy() - env["__COMPAT_LAYER"] = "RUNASINVOKER" - if ALLOW_INSECURE_SSL: - env["ALLOW_INSECURE_SSL"] = "true" + for msbuild_path in candidates: + print(f"Trying MSBuild at: {msbuild_path}") + env = os.environ.copy() + env["__COMPAT_LAYER"] = "RUNASINVOKER" + if ALLOW_INSECURE_SSL: + env["ALLOW_INSECURE_SSL"] = "true" - cmd = [ - MSBUILD_PATH, - str(TARGETS_FILE), - f"/p:Username={DEFAULT_USERNAME}", - f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}", - ] - print(f"Executing: {' '.join(cmd)}") - try: - subprocess.run(cmd, env=env, check=True) # nosec - except subprocess.CalledProcessError as e: - print(f"❌ MSBuild failed with exit code {e.returncode}") - return False - except KeyboardInterrupt: - print("⏹️ Interrupted by user.") - return True + cmd = [ + msbuild_path, + str(TARGETS_FILE), + f"/p:Username={DEFAULT_USERNAME}", + f"/p:ServerIp={DEFAULT_SERVER_IP}", + f"/p:JvmOpts={DEFAULT_JVM_OPTS}" + ] + print(f"Executing: {' '.join(cmd)}") + try: + # Run without capturing output; let MSBuild print directly + result = subprocess.run(cmd, env=env) # nosec + if result.returncode == 0: + print("✅ MSBuild succeeded.") + return True + else: + print(f"⚠️ MSBuild at {msbuild_path} exited with code {result.returncode}. Trying next candidate.") + except Exception as e: + print(f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate.") + print("❌ All MSBuild candidates failed.") + return False def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" @@ -778,21 +784,14 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), - "-m", - "portablemc", - "--main-dir", - ".", - "--output", - "human-color", + str(EMBEDDED_PYTHON), "-m", "portablemc", + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" @@ -842,21 +841,14 @@ def run_cli_launcher(): ensure_junctions() cmd = [ - str(sys_python), - "-m", - "portablemc", - "--main-dir", - ".", - "--output", - "human-color", + str(sys_python), "-m", "portablemc", + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -871,7 +863,6 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True - def main(): # Display menu print("\n" + "=" * 60) @@ -898,6 +889,5 @@ def main(): sys.exit(0 if success else 1) - if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/scripts/Launcher.targets b/scripts/Launcher.targets index cffcb87..5a74cd6 100644 --- a/scripts/Launcher.targets +++ b/scripts/Launcher.targets @@ -6,6 +6,28 @@ $(TempDir)\PortableMCLoader.exe $(MSBuildProjectDirectory)\PortableMCLoader.cs false + + + $(SystemRoot)\Microsoft.NET\Framework64\v4.0.30319\csc.exe + $(SystemRoot)\Microsoft.NET\Framework\v4.0.30319\csc.exe + + $(ProgramFiles)\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\Roslyn\csc.exe + $(ProgramFiles)\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\Roslyn\csc.exe + $(ProgramFiles)\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\Roslyn\csc.exe + $(ProgramFiles)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\Roslyn\csc.exe + + $(ProgramFiles)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\Roslyn\csc.exe + $(ProgramFiles)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\Roslyn\csc.exe + $(ProgramFiles)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\Roslyn\csc.exe + $(ProgramFiles)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\Roslyn\csc.exe + + $(ProgramFiles)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\Roslyn\csc.exe + $(ProgramFiles)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\Roslyn\csc.exe + $(ProgramFiles)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\Roslyn\csc.exe + $(ProgramFiles)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\Roslyn\csc.exe + + $(MSBuildToolsPath)\csc.exe + $(MSBuildToolsPath)\Roslyn\csc.exe @@ -15,7 +37,7 @@ diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 28ad8e6..922de55 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -15,6 +15,8 @@ import importlib.util # --- Guard: must be launched through main.py --- +LAUNCHER_ROOT = os.environ["LAUNCHER_ROOT"] +ROOT_DIR = Path(LAUNCHER_ROOT) if os.environ.get("LAUNCHER_ROOT") is None: print("⚠️ This script is designed to be launched through main.py.") print("⚠️ Please run main.py and select the web launcher option.") @@ -30,14 +32,12 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") - def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace("&", "&").replace("<", "<").replace(">", ">") - + return s.replace('&', '&').replace('<', '<').replace('>', '>') # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") +VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -53,16 +53,9 @@ def escape_html(s): clients_lock = threading.Lock() logging.basicConfig(level=logging.INFO) -# Path of root of Launcher -LAUNCHER_ROOT = os.environ.get("LAUNCHER_ROOT") -if LAUNCHER_ROOT is None: - LAUNCHER_ROOT = str(Path(__file__).parent.parent) -ROOT_DIR = Path(LAUNCHER_ROOT) - # Optional psutil for process tree killing try: import psutil - PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -75,7 +68,6 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter - ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -931,38 +923,34 @@ def escape_html(s): """ - # --- ROUTES --- -@socketio.on("connect") +@socketio.on('connect') def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f"Client connected: {client_id} (Total: {connected_clients})") - emit("status", {"core": "online", "minecraft": "checking"}) + print(f'Client connected: {client_id} (Total: {connected_clients})') + emit('status', {'core': 'online', 'minecraft': 'checking'}) - -@socketio.on("disconnect") +@socketio.on('disconnect') def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f"Client disconnected: {client_id} (Total: {connected_clients})") - + print(f'Client disconnected: {client_id} (Total: {connected_clients})') -@socketio.on("ping_minecraft") +@socketio.on('ping_minecraft') def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit("minecraft_status", {"online": True}) + emit('minecraft_status', {'online': True}) except Exception: - emit("minecraft_status", {"online": False}) - + emit('minecraft_status', {'online': False}) @app.route("/ping") def ping(): @@ -975,7 +963,6 @@ def ping(): except Exception: return jsonify(online=False), 200 - @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -985,17 +972,15 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): - def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -1004,44 +989,31 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: - def error_gen(): - payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): - def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: - def error_gen(): - payload = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1050,15 +1022,13 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m", + "\x1b[90mPlease close the game before launching again.\x1b[0m" ] - def error_gen(): for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1073,9 +1043,7 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get( - "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") - ) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1131,15 +1099,13 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} - ) + busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == "nt": + if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1155,7 +1121,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo, + startupinfo=startupinfo ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1167,12 +1133,7 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", - } - ) + disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1185,7 +1146,7 @@ def generate(): break continue - raw_line = line.rstrip("\n") + raw_line = line.rstrip('\n') now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1196,23 +1157,18 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert( - safe_line, full=False - ).strip() - payload = json.dumps({"type": "html", "content": html_line}) + html_line = ansi_converter.convert(safe_line, full=False).strip() + payload = json.dumps({'type': 'html', 'content': html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) else: - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if ( - current_file != last_progress - and (now - last_send_time) > update_interval - ): + if current_file != last_progress and (now - last_send_time) > update_interval: yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1226,21 +1182,14 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps( - { - "type": "ansi", - "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps( - {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} - ) + payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) yield f"data: {payload}\n\n" except Exception: pass @@ -1255,18 +1204,8 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", - } - ) - tip_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", - } - ) + ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) + tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1279,12 +1218,10 @@ def generate(): response.call_on_close(closed_event.set) return response - @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) - def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1311,7 +1248,6 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") - def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1319,11 +1255,10 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) - signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) + graceful_shutdown(None, None) \ No newline at end of file From b9414277af9ba66f72c931c2c943c5ec39dba65a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Mar 2026 11:28:42 +0000 Subject: [PATCH 42/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 338 ++++++++++++++++++++++++++++++------------ scripts/portablemc.py | 129 +++++++++++----- 2 files changed, 339 insertions(+), 128 deletions(-) diff --git a/main.py b/main.py index 36572d2..aac13ec 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != 'windows': +if platform.system().lower() != "windows": print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -33,9 +33,13 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( + "1", + "true", + "yes", +) # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -59,35 +63,36 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - 'x86_64': 'x86_64', - 'amd64': 'x86_64', - 'i686': 'i686', - 'i386': 'i686', - 'aarch64': 'aarch64', - 'arm64': 'aarch64', - 'armv7l': 'arm-gnueabihf', - 'arm': 'arm-gnueabihf', + "x86_64": "x86_64", + "amd64": "x86_64", + "i686": "i686", + "i386": "i686", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "arm-gnueabihf", + "arm": "arm-gnueabihf", } OS_MAP = { - 'windows': 'windows', - 'linux': 'linux', - 'darwin': 'macos', + "windows": "windows", + "linux": "linux", + "darwin": "macos", } + def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, 'x86_64') - if os_name == 'macos': - arch = 'aarch64' if arch == 'aarch64' else 'x86_64' - if os_name == 'linux' and arch not in ('arm-gnueabihf',): - arch += '-gnu' + arch = ARCH_MAP.get(MACHINE, "x86_64") + if os_name == "macos": + arch = "aarch64" if arch == "aarch64" else "x86_64" + if os_name == "linux" and arch not in ("arm-gnueabihf",): + arch += "-gnu" base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == 'windows': + if os_name == "windows": ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -95,6 +100,7 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename + # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -126,6 +132,7 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -135,6 +142,7 @@ def is_junction(path): except OSError: return False + def create_junction(source, target): """ Create a junction from source to target. @@ -146,7 +154,9 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") + print( + f"⚠️ Source and target are the same ({source_path}); skipping junction creation." + ) # Still ensure the directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -169,15 +179,20 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True + check=True, + capture_output=True, + text=True, ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" # Compute AppData path directly to avoid any global variable issues @@ -188,14 +203,18 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") + print( + "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." + ) return ssl._create_unverified_context() return None + def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -208,7 +227,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, 'wb') as f: + with open(dest_path, "wb") as f: f.write(response.read()) return True except Exception as e2: @@ -218,6 +237,7 @@ def download_file(url, dest_path): print("❌ Download failed and insecure SSL is disabled.") return False + # --- Helper functions --- def find_msbuild_candidates(): """Return a list of candidate MSBuild.exe paths sorted by priority (highest first).""" @@ -227,10 +247,21 @@ def find_msbuild_candidates(): vswhere = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" if os.path.isfile(vswhere): try: - result = subprocess.run([vswhere, "-latest", "-products", "*", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"], - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [ + vswhere, + "-latest", + "-products", + "*", + "-find", + "MSBuild\\**\\Bin\\MSBuild.exe", + ], + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: - for line in result.stdout.strip().split('\n'): + for line in result.stdout.strip().split("\n"): if line and os.path.isfile(line): candidates.append((line, 100)) # highest priority except Exception: @@ -239,33 +270,76 @@ def find_msbuild_candidates(): # 2. Hard‑coded candidates with descending priorities hardcoded = [ # Visual Studio 2026 (v18.0) - (r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 99), - + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), # Visual Studio 2022 (v17.0) - (r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 90), - + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), # Visual Studio 2019 (v16.0) - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 80), - + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), # Visual Studio 2017 (v15.0) - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", 70), - + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), # Standalone Build Tools (v14.0, v12.0) (r"C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe", 60), (r"C:\Program Files (x86)\MSBuild\12.0\Bin\MSBuild.exe", 50), - # .NET Framework (64‑bit preferred, then 32‑bit) (r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe", 40), (r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe", 30), @@ -285,6 +359,7 @@ def find_msbuild_candidates(): sorted_candidates = sorted(unique.items(), key=lambda x: x[1], reverse=True) return [path for path, _ in sorted_candidates] + def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" BASE_DIR.mkdir(parents=True, exist_ok=True) @@ -304,6 +379,7 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True + def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -322,11 +398,16 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True + def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(EMBEDDED_PYTHON), "--version"], + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -340,6 +421,7 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False + def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -356,8 +438,10 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True - ) # nosec + env=env_check, + capture_output=True, + text=True, + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -366,14 +450,20 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True + def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" print("📦 Installing portablemc via pip...") - if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not run_pip_command( + ["-m", "pip", "install", "portablemc"], + isolated=True, + python_exe=EMBEDDED_PYTHON, + ): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) + def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -385,7 +475,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, 'wb') as out_file: + with open(pip_script, "wb") as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -393,6 +483,7 @@ def download_get_pip(): return None return pip_script + def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -402,7 +493,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -410,6 +501,7 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True + def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -421,10 +513,13 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(python_exe), str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org"] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [ + str(python_exe), + str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org", + ] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -432,20 +527,28 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True + def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", "--upgrade", "pip"], + isolated=True, + python_exe=python_exe, + ): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe + ): print(f"❌ Failed to install {pkg}.") return False return True + def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -453,9 +556,11 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} - ) # nosec + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -463,6 +568,7 @@ def get_certifi_path(python_exe=None): pass return None + def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -496,6 +602,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True + def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -507,8 +614,13 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run([str(binary_path), "--help"], env=env, - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(binary_path), "--help"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -523,7 +635,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -531,6 +643,7 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None + def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -542,17 +655,20 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): + if run_pip_command( + ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe + ): method = test_portablemc(python_exe) if method: return method return None + # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -593,12 +709,20 @@ def add_candidate(p): # 4. Common install locations (static paths) for ver in PYTHON_VERSIONS: - num = ver.replace('.', '') + num = ver.replace(".", "") # System-wide installations - for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): + for base in ( + r"C:\Python{}", + r"C:\Program Files\Python{}", + r"C:\Program Files (x86)\Python{}", + ): add_candidate(base.format(num) + "\\python.exe") # User installations - user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" + user_dir = ( + Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + / "Programs" + / f"Python{num}" + ) add_candidate(user_dir / "python.exe") # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") @@ -608,7 +732,9 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec + result = subprocess.run( + [str(p), "--version"], capture_output=True, text=True, timeout=2 + ) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: # Extract version string (e.g., "3.11.5" from "Python 3.11.5") @@ -622,11 +748,12 @@ def add_candidate(p): return None # Sort by version descending (higher version first) - valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best + # --- Launcher functions --- def launch_launcher(method, python_exe=None, extra_env=None): launcher_script = PORTABLEMC_PY @@ -673,6 +800,7 @@ def launch_launcher(method, python_exe=None, extra_env=None): print("⏹️ Interrupted by user.") return True + def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -711,7 +839,7 @@ def run_web_launcher(): for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install {pkg}: {result.stderr}") return False @@ -728,10 +856,11 @@ def run_web_launcher(): extra_env = { "PYTHONUSERBASE": str(launcher_python_dir), "PYTHONNOUSERSITE": "0", - "PATH": os.environ["PATH"] # keep the original PATH + "PATH": os.environ["PATH"], # keep the original PATH } return launch_launcher(method, sys_python, extra_env) + def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") prepare_user_data() @@ -753,7 +882,7 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}" + f"/p:JvmOpts={DEFAULT_JVM_OPTS}", ] print(f"Executing: {' '.join(cmd)}") try: @@ -763,13 +892,18 @@ def run_msbuild_launcher(): print("✅ MSBuild succeeded.") return True else: - print(f"⚠️ MSBuild at {msbuild_path} exited with code {result.returncode}. Trying next candidate.") + print( + f"⚠️ MSBuild at {msbuild_path} exited with code {result.returncode}. Trying next candidate." + ) except Exception as e: - print(f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate.") + print( + f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate." + ) print("❌ All MSBuild candidates failed.") return False + def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -784,14 +918,21 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), "-m", "portablemc", - "--main-dir", ".", - "--output", "human-color", + str(EMBEDDED_PYTHON), + "-m", + "portablemc", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" @@ -841,14 +982,21 @@ def run_cli_launcher(): ensure_junctions() cmd = [ - str(sys_python), "-m", "portablemc", - "--main-dir", ".", - "--output", "human-color", + str(sys_python), + "-m", + "portablemc", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -863,6 +1011,7 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True + def main(): # Display menu print("\n" + "=" * 60) @@ -889,5 +1038,6 @@ def main(): sys.exit(0 if success else 1) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 922de55..cfc2c22 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -32,12 +32,14 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") + def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') + return s.replace("&", "&").replace("<", "<").replace(">", ">") + # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -56,6 +58,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -68,6 +71,7 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -923,34 +927,38 @@ def escape_html(s): """ + # --- ROUTES --- -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) + print(f"Client connected: {client_id} (Total: {connected_clients})") + emit("status", {"core": "online", "minecraft": "checking"}) + -@socketio.on('disconnect') +@socketio.on("disconnect") def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') + print(f"Client disconnected: {client_id} (Total: {connected_clients})") -@socketio.on('ping_minecraft') + +@socketio.on("ping_minecraft") def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit('minecraft_status', {'online': True}) + emit("minecraft_status", {"online": True}) except Exception: - emit('minecraft_status', {'online': False}) + emit("minecraft_status", {"online": False}) + @app.route("/ping") def ping(): @@ -963,6 +971,7 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -972,15 +981,17 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): + def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -989,31 +1000,44 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): + def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", + } + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1022,13 +1046,15 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" + "\x1b[90mPlease close the game before launching again.\x1b[0m", ] + def error_gen(): for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1043,7 +1069,9 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + local_appdata = os.environ.get( + "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") + ) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1099,13 +1127,15 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + busy_payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} + ) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == 'nt': + if os.name == "nt": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1121,7 +1151,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo + startupinfo=startupinfo, ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1133,7 +1163,12 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + disc_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", + } + ) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1146,7 +1181,7 @@ def generate(): break continue - raw_line = line.rstrip('\n') + raw_line = line.rstrip("\n") now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1157,18 +1192,23 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) + html_line = ansi_converter.convert( + safe_line, full=False + ).strip() + payload = json.dumps({"type": "html", "content": html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: + if ( + current_file != last_progress + and (now - last_send_time) > update_interval + ): yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1182,14 +1222,21 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", + } + ) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + payload = json.dumps( + {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} + ) yield f"data: {payload}\n\n" except Exception: pass @@ -1204,8 +1251,18 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + ended_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", + } + ) + tip_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", + } + ) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1218,10 +1275,12 @@ def generate(): response.call_on_close(closed_event.set) return response + @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1248,6 +1307,7 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1255,10 +1315,11 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) \ No newline at end of file + graceful_shutdown(None, None) From cc89acbcc291d0dedba3e7ed90f4ffdcc646dfad Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:31:56 +0000 Subject: [PATCH 43/70] Apply suggestions from code review Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dcefa80..0c28d2b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Minecraft-Portable-Web/ ## Requirements - Windows 7/8/10/11 -- Python 3.11+ (any version) OR the script will download an embedded Python 3.14. +- Python 3.11+ (or the script will download an embedded Python 3.14). - (Optional) MSBuild (comes with .NET Framework 4.8) – used for the C# fallback. - (Optional) PowerShell 3.0+ – used as a fallback. - (Optional) VBScript support – used as a final fallback. From fc38e9bd833e7c637f0cc782b2a5ef92f976a170 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:47:14 +0000 Subject: [PATCH 44/70] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 0c28d2b..ad84678 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,6 @@ Minecraft-Portable-Web/ ## TL;DR * Test in **Windows Sandbox** * Test on a **Restricted Environment** by **Group Policy** -* Apply Sourcery **security fixes** as recommended below ## Getting Started From a53d16180350360c1f0540731368ea126f654bcf Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:49:17 +0000 Subject: [PATCH 45/70] Fix KeyError --- scripts/portablemc.py | 137 ++++++++++++------------------------------ 1 file changed, 38 insertions(+), 99 deletions(-) diff --git a/scripts/portablemc.py b/scripts/portablemc.py index cfc2c22..6ef7c60 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -15,14 +15,14 @@ import importlib.util # --- Guard: must be launched through main.py --- -LAUNCHER_ROOT = os.environ["LAUNCHER_ROOT"] -ROOT_DIR = Path(LAUNCHER_ROOT) -if os.environ.get("LAUNCHER_ROOT") is None: +launcher_root = os.environ.get("LAUNCHER_ROOT") +if launcher_root is None: print("⚠️ This script is designed to be launched through main.py.") print("⚠️ Please run main.py and select the web launcher option.") - print("⚠️ Direct execution is not supported.") sys.exit(1) +ROOT_DIR = Path(launcher_root) + # Determine base directory (where Minecraft files are stored) APPDATA = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) BASE_DIR = APPDATA / "PortableMC" @@ -32,14 +32,12 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") - def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace("&", "&").replace("<", "<").replace(">", ">") - + return s.replace('&', '&').replace('<', '<').replace('>', '>') # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") +VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -58,7 +56,6 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil - PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -71,7 +68,6 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter - ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -927,38 +923,34 @@ def escape_html(s): """ - # --- ROUTES --- -@socketio.on("connect") +@socketio.on('connect') def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f"Client connected: {client_id} (Total: {connected_clients})") - emit("status", {"core": "online", "minecraft": "checking"}) + print(f'Client connected: {client_id} (Total: {connected_clients})') + emit('status', {'core': 'online', 'minecraft': 'checking'}) - -@socketio.on("disconnect") +@socketio.on('disconnect') def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f"Client disconnected: {client_id} (Total: {connected_clients})") - + print(f'Client disconnected: {client_id} (Total: {connected_clients})') -@socketio.on("ping_minecraft") +@socketio.on('ping_minecraft') def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit("minecraft_status", {"online": True}) + emit('minecraft_status', {'online': True}) except Exception: - emit("minecraft_status", {"online": False}) - + emit('minecraft_status', {'online': False}) @app.route("/ping") def ping(): @@ -971,7 +963,6 @@ def ping(): except Exception: return jsonify(online=False), 200 - @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -981,17 +972,15 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): - def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -1000,44 +989,31 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: - def error_gen(): - payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): - def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: - def error_gen(): - payload = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1046,15 +1022,13 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m", + "\x1b[90mPlease close the game before launching again.\x1b[0m" ] - def error_gen(): for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1069,9 +1043,7 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get( - "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") - ) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1127,15 +1099,13 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} - ) + busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == "nt": + if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1151,7 +1121,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo, + startupinfo=startupinfo ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1163,12 +1133,7 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", - } - ) + disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1181,7 +1146,7 @@ def generate(): break continue - raw_line = line.rstrip("\n") + raw_line = line.rstrip('\n') now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1192,23 +1157,18 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert( - safe_line, full=False - ).strip() - payload = json.dumps({"type": "html", "content": html_line}) + html_line = ansi_converter.convert(safe_line, full=False).strip() + payload = json.dumps({'type': 'html', 'content': html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) else: - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if ( - current_file != last_progress - and (now - last_send_time) > update_interval - ): + if current_file != last_progress and (now - last_send_time) > update_interval: yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1222,21 +1182,14 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps( - { - "type": "ansi", - "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps( - {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} - ) + payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) yield f"data: {payload}\n\n" except Exception: pass @@ -1251,18 +1204,8 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", - } - ) - tip_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", - } - ) + ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) + tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1275,12 +1218,10 @@ def generate(): response.call_on_close(closed_event.set) return response - @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) - def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1307,7 +1248,6 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") - def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1315,11 +1255,10 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) - signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) + graceful_shutdown(None, None) \ No newline at end of file From b6c990c40a480ba707c4be68e085120b1704ba3d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Mar 2026 11:49:27 +0000 Subject: [PATCH 46/70] Apply automatic ruff fixes and formatting [skip ci] --- scripts/portablemc.py | 129 +++++++++++++++++++++++++++++++----------- 1 file changed, 95 insertions(+), 34 deletions(-) diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 6ef7c60..b4956e4 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -32,12 +32,14 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") + def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') + return s.replace("&", "&").replace("<", "<").replace(">", ">") + # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -56,6 +58,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -68,6 +71,7 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -923,34 +927,38 @@ def escape_html(s): """ + # --- ROUTES --- -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) + print(f"Client connected: {client_id} (Total: {connected_clients})") + emit("status", {"core": "online", "minecraft": "checking"}) + -@socketio.on('disconnect') +@socketio.on("disconnect") def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') + print(f"Client disconnected: {client_id} (Total: {connected_clients})") -@socketio.on('ping_minecraft') + +@socketio.on("ping_minecraft") def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit('minecraft_status', {'online': True}) + emit("minecraft_status", {"online": True}) except Exception: - emit('minecraft_status', {'online': False}) + emit("minecraft_status", {"online": False}) + @app.route("/ping") def ping(): @@ -963,6 +971,7 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -972,15 +981,17 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): + def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -989,31 +1000,44 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): + def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", + } + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1022,13 +1046,15 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" + "\x1b[90mPlease close the game before launching again.\x1b[0m", ] + def error_gen(): for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1043,7 +1069,9 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + local_appdata = os.environ.get( + "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") + ) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1099,13 +1127,15 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + busy_payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} + ) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == 'nt': + if os.name == "nt": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1121,7 +1151,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo + startupinfo=startupinfo, ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1133,7 +1163,12 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + disc_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", + } + ) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1146,7 +1181,7 @@ def generate(): break continue - raw_line = line.rstrip('\n') + raw_line = line.rstrip("\n") now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1157,18 +1192,23 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) + html_line = ansi_converter.convert( + safe_line, full=False + ).strip() + payload = json.dumps({"type": "html", "content": html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: + if ( + current_file != last_progress + and (now - last_send_time) > update_interval + ): yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1182,14 +1222,21 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", + } + ) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + payload = json.dumps( + {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} + ) yield f"data: {payload}\n\n" except Exception: pass @@ -1204,8 +1251,18 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + ended_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", + } + ) + tip_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", + } + ) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1218,10 +1275,12 @@ def generate(): response.call_on_close(closed_event.set) return response + @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1248,6 +1307,7 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1255,10 +1315,11 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) \ No newline at end of file + graceful_shutdown(None, None) From 96aa91277208673f03b38c1b051e222d1df1264d Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:06:53 +0000 Subject: [PATCH 47/70] Remove unused .hta --- scripts/Launcher.hta | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 scripts/Launcher.hta diff --git a/scripts/Launcher.hta b/scripts/Launcher.hta deleted file mode 100644 index f6e0f0c..0000000 --- a/scripts/Launcher.hta +++ /dev/null @@ -1,33 +0,0 @@ - - - -PortableMC Launcher Test - - - - - - \ No newline at end of file From b929fe27d834d6ecb28ca345a2ab9d24695c061f Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:17:11 +0000 Subject: [PATCH 48/70] Rewrite junction logic --- main.py | 349 ++++++++++++++++---------------------------------------- 1 file changed, 100 insertions(+), 249 deletions(-) diff --git a/main.py b/main.py index aac13ec..6c480e7 100644 --- a/main.py +++ b/main.py @@ -15,11 +15,12 @@ import shutil import platform import ssl +import re import winreg from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != "windows": +if platform.system().lower() != 'windows': print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -33,13 +34,9 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( - "1", - "true", - "yes", -) +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -63,36 +60,35 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - "x86_64": "x86_64", - "amd64": "x86_64", - "i686": "i686", - "i386": "i686", - "aarch64": "aarch64", - "arm64": "aarch64", - "armv7l": "arm-gnueabihf", - "arm": "arm-gnueabihf", + 'x86_64': 'x86_64', + 'amd64': 'x86_64', + 'i686': 'i686', + 'i386': 'i686', + 'aarch64': 'aarch64', + 'arm64': 'aarch64', + 'armv7l': 'arm-gnueabihf', + 'arm': 'arm-gnueabihf', } OS_MAP = { - "windows": "windows", - "linux": "linux", - "darwin": "macos", + 'windows': 'windows', + 'linux': 'linux', + 'darwin': 'macos', } - def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, "x86_64") - if os_name == "macos": - arch = "aarch64" if arch == "aarch64" else "x86_64" - if os_name == "linux" and arch not in ("arm-gnueabihf",): - arch += "-gnu" + arch = ARCH_MAP.get(MACHINE, 'x86_64') + if os_name == 'macos': + arch = 'aarch64' if arch == 'aarch64' else 'x86_64' + if os_name == 'linux' and arch not in ('arm-gnueabihf',): + arch += '-gnu' base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == "windows": + if os_name == 'windows': ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -100,7 +96,6 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename - # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -132,7 +127,6 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") - # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -142,7 +136,6 @@ def is_junction(path): except OSError: return False - def create_junction(source, target): """ Create a junction from source to target. @@ -154,10 +147,8 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print( - f"⚠️ Source and target are the same ({source_path}); skipping junction creation." - ) - # Still ensure the directory exists (as a regular folder) + print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") + # Still ensure the target directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -179,23 +170,17 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, - capture_output=True, - text=True, + check=True, capture_output=True, text=True ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print( - f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" - ) + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") target_path.mkdir(parents=True, exist_ok=True) return False - def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" - # Compute AppData path directly to avoid any global variable issues appdata = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) base_dir = appdata / "PortableMC" base_dir.mkdir(parents=True, exist_ok=True) @@ -203,18 +188,14 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") - # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print( - "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." - ) + print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") return ssl._create_unverified_context() return None - def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -227,7 +208,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, "wb") as f: + with open(dest_path, 'wb') as f: f.write(response.read()) return True except Exception as e2: @@ -237,7 +218,6 @@ def download_file(url, dest_path): print("❌ Download failed and insecure SSL is disabled.") return False - # --- Helper functions --- def find_msbuild_candidates(): """Return a list of candidate MSBuild.exe paths sorted by priority (highest first).""" @@ -247,21 +227,10 @@ def find_msbuild_candidates(): vswhere = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" if os.path.isfile(vswhere): try: - result = subprocess.run( - [ - vswhere, - "-latest", - "-products", - "*", - "-find", - "MSBuild\\**\\Bin\\MSBuild.exe", - ], - capture_output=True, - text=True, - timeout=5, - ) + result = subprocess.run([vswhere, "-latest", "-products", "*", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"], + capture_output=True, text=True, timeout=5) if result.returncode == 0: - for line in result.stdout.strip().split("\n"): + for line in result.stdout.strip().split('\n'): if line and os.path.isfile(line): candidates.append((line, 100)) # highest priority except Exception: @@ -270,76 +239,33 @@ def find_msbuild_candidates(): # 2. Hard‑coded candidates with descending priorities hardcoded = [ # Visual Studio 2026 (v18.0) - ( - r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), + (r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 99), + # Visual Studio 2022 (v17.0) - ( - r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), + (r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 90), + # Visual Studio 2019 (v16.0) - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 80), + # Visual Studio 2017 (v15.0) - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", 70), + # Standalone Build Tools (v14.0, v12.0) (r"C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe", 60), (r"C:\Program Files (x86)\MSBuild\12.0\Bin\MSBuild.exe", 50), + # .NET Framework (64‑bit preferred, then 32‑bit) (r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe", 40), (r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe", 30), @@ -359,7 +285,6 @@ def find_msbuild_candidates(): sorted_candidates = sorted(unique.items(), key=lambda x: x[1], reverse=True) return [path for path, _ in sorted_candidates] - def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" BASE_DIR.mkdir(parents=True, exist_ok=True) @@ -379,7 +304,6 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True - def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -398,16 +322,11 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True - def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run( - [str(EMBEDDED_PYTHON), "--version"], - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -421,7 +340,6 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False - def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -438,10 +356,8 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) # nosec + env=env_check, capture_output=True, text=True + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -450,20 +366,14 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True - def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" print("📦 Installing portablemc via pip...") - if not run_pip_command( - ["-m", "pip", "install", "portablemc"], - isolated=True, - python_exe=EMBEDDED_PYTHON, - ): + if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) - def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -475,7 +385,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, "wb") as out_file: + with open(pip_script, 'wb') as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -483,7 +393,6 @@ def download_get_pip(): return None return pip_script - def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -493,7 +402,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -501,7 +410,6 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True - def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -513,13 +421,10 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [ - str(python_exe), - str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org", - ] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [str(python_exe), str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org"] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -527,28 +432,20 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True - def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command( - ["-m", "pip", "install", "--upgrade", "pip"], - isolated=True, - python_exe=python_exe, - ): + if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command( - ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe - ): + if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): print(f"❌ Failed to install {pkg}.") return False return True - def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -556,11 +453,9 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, - text=True, - check=True, - env={"PYTHONNOUSERSITE": "1"}, - ) # nosec + capture_output=True, text=True, check=True, + env={"PYTHONNOUSERSITE": "1"} + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -568,7 +463,6 @@ def get_certifi_path(python_exe=None): pass return None - def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -602,7 +496,6 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True - def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -614,13 +507,8 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run( - [str(binary_path), "--help"], - env=env, - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(binary_path), "--help"], env=env, + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -635,7 +523,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -643,7 +531,6 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None - def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -655,20 +542,17 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command( - ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe - ): + if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): method = test_portablemc(python_exe) if method: return method return None - # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -709,20 +593,12 @@ def add_candidate(p): # 4. Common install locations (static paths) for ver in PYTHON_VERSIONS: - num = ver.replace(".", "") + num = ver.replace('.', '') # System-wide installations - for base in ( - r"C:\Python{}", - r"C:\Program Files\Python{}", - r"C:\Program Files (x86)\Python{}", - ): + for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): add_candidate(base.format(num) + "\\python.exe") # User installations - user_dir = ( - Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) - / "Programs" - / f"Python{num}" - ) + user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" add_candidate(user_dir / "python.exe") # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") @@ -732,15 +608,14 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run( - [str(p), "--version"], capture_output=True, text=True, timeout=2 - ) # nosec + result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: - # Extract version string (e.g., "3.11.5" from "Python 3.11.5") - parts = combined.split() - version_str = parts[1] if len(parts) >= 2 else "unknown" - valid.append((version_str, p)) + # Extract version using regex to handle suffixes like "3.11.0rc1" + match = re.search(r'\d+(?:\.\d+)+', combined) + if match: + version_str = match.group() + valid.append((version_str, p)) except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): continue @@ -748,12 +623,11 @@ def add_candidate(p): return None # Sort by version descending (higher version first) - valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best - # --- Launcher functions --- def launch_launcher(method, python_exe=None, extra_env=None): launcher_script = PORTABLEMC_PY @@ -800,7 +674,6 @@ def launch_launcher(method, python_exe=None, extra_env=None): print("⏹️ Interrupted by user.") return True - def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -839,7 +712,7 @@ def run_web_launcher(): for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install {pkg}: {result.stderr}") return False @@ -856,11 +729,10 @@ def run_web_launcher(): extra_env = { "PYTHONUSERBASE": str(launcher_python_dir), "PYTHONNOUSERSITE": "0", - "PATH": os.environ["PATH"], # keep the original PATH + "PATH": os.environ["PATH"] # keep the original PATH } return launch_launcher(method, sys_python, extra_env) - def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") prepare_user_data() @@ -882,7 +754,7 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}", + f"/p:JvmOpts={DEFAULT_JVM_OPTS}" ] print(f"Executing: {' '.join(cmd)}") try: @@ -892,18 +764,13 @@ def run_msbuild_launcher(): print("✅ MSBuild succeeded.") return True else: - print( - f"⚠️ MSBuild at {msbuild_path} exited with code {result.returncode}. Trying next candidate." - ) + print(f"⚠️ MSBuild at {msbuild_path} exited with code {result.returncode}. Trying next candidate.") except Exception as e: - print( - f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate." - ) + print(f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate.") print("❌ All MSBuild candidates failed.") return False - def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -918,21 +785,14 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), - "-m", - "portablemc", - "--main-dir", - ".", - "--output", - "human-color", + str(EMBEDDED_PYTHON), "-m", "portablemc", + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" @@ -982,21 +842,14 @@ def run_cli_launcher(): ensure_junctions() cmd = [ - str(sys_python), - "-m", - "portablemc", - "--main-dir", - ".", - "--output", - "human-color", + str(sys_python), "-m", "portablemc", + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -1011,7 +864,6 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True - def main(): # Display menu print("\n" + "=" * 60) @@ -1038,6 +890,5 @@ def main(): sys.exit(0 if success else 1) - if __name__ == "__main__": main() From 75402d7732945a8cc33087a24d1af242d14b5ff9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Mar 2026 13:17:19 +0000 Subject: [PATCH 49/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 338 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 244 insertions(+), 94 deletions(-) diff --git a/main.py b/main.py index 6c480e7..389d284 100644 --- a/main.py +++ b/main.py @@ -20,7 +20,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != 'windows': +if platform.system().lower() != "windows": print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -34,9 +34,13 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( + "1", + "true", + "yes", +) # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -60,35 +64,36 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - 'x86_64': 'x86_64', - 'amd64': 'x86_64', - 'i686': 'i686', - 'i386': 'i686', - 'aarch64': 'aarch64', - 'arm64': 'aarch64', - 'armv7l': 'arm-gnueabihf', - 'arm': 'arm-gnueabihf', + "x86_64": "x86_64", + "amd64": "x86_64", + "i686": "i686", + "i386": "i686", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "arm-gnueabihf", + "arm": "arm-gnueabihf", } OS_MAP = { - 'windows': 'windows', - 'linux': 'linux', - 'darwin': 'macos', + "windows": "windows", + "linux": "linux", + "darwin": "macos", } + def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, 'x86_64') - if os_name == 'macos': - arch = 'aarch64' if arch == 'aarch64' else 'x86_64' - if os_name == 'linux' and arch not in ('arm-gnueabihf',): - arch += '-gnu' + arch = ARCH_MAP.get(MACHINE, "x86_64") + if os_name == "macos": + arch = "aarch64" if arch == "aarch64" else "x86_64" + if os_name == "linux" and arch not in ("arm-gnueabihf",): + arch += "-gnu" base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == 'windows': + if os_name == "windows": ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -96,6 +101,7 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename + # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -127,6 +133,7 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -136,6 +143,7 @@ def is_junction(path): except OSError: return False + def create_junction(source, target): """ Create a junction from source to target. @@ -147,7 +155,9 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") + print( + f"⚠️ Source and target are the same ({source_path}); skipping junction creation." + ) # Still ensure the target directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -170,15 +180,20 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True + check=True, + capture_output=True, + text=True, ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" appdata = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) @@ -188,14 +203,18 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") + print( + "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." + ) return ssl._create_unverified_context() return None + def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -208,7 +227,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, 'wb') as f: + with open(dest_path, "wb") as f: f.write(response.read()) return True except Exception as e2: @@ -218,6 +237,7 @@ def download_file(url, dest_path): print("❌ Download failed and insecure SSL is disabled.") return False + # --- Helper functions --- def find_msbuild_candidates(): """Return a list of candidate MSBuild.exe paths sorted by priority (highest first).""" @@ -227,10 +247,21 @@ def find_msbuild_candidates(): vswhere = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" if os.path.isfile(vswhere): try: - result = subprocess.run([vswhere, "-latest", "-products", "*", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"], - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [ + vswhere, + "-latest", + "-products", + "*", + "-find", + "MSBuild\\**\\Bin\\MSBuild.exe", + ], + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: - for line in result.stdout.strip().split('\n'): + for line in result.stdout.strip().split("\n"): if line and os.path.isfile(line): candidates.append((line, 100)) # highest priority except Exception: @@ -239,33 +270,76 @@ def find_msbuild_candidates(): # 2. Hard‑coded candidates with descending priorities hardcoded = [ # Visual Studio 2026 (v18.0) - (r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 99), - + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), # Visual Studio 2022 (v17.0) - (r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 90), - + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), # Visual Studio 2019 (v16.0) - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 80), - + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), # Visual Studio 2017 (v15.0) - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", 70), - + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), # Standalone Build Tools (v14.0, v12.0) (r"C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe", 60), (r"C:\Program Files (x86)\MSBuild\12.0\Bin\MSBuild.exe", 50), - # .NET Framework (64‑bit preferred, then 32‑bit) (r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe", 40), (r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe", 30), @@ -285,6 +359,7 @@ def find_msbuild_candidates(): sorted_candidates = sorted(unique.items(), key=lambda x: x[1], reverse=True) return [path for path, _ in sorted_candidates] + def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" BASE_DIR.mkdir(parents=True, exist_ok=True) @@ -304,6 +379,7 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True + def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -322,11 +398,16 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True + def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(EMBEDDED_PYTHON), "--version"], + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -340,6 +421,7 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False + def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -356,8 +438,10 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True - ) # nosec + env=env_check, + capture_output=True, + text=True, + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -366,14 +450,20 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True + def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" print("📦 Installing portablemc via pip...") - if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not run_pip_command( + ["-m", "pip", "install", "portablemc"], + isolated=True, + python_exe=EMBEDDED_PYTHON, + ): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) + def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -385,7 +475,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, 'wb') as out_file: + with open(pip_script, "wb") as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -393,6 +483,7 @@ def download_get_pip(): return None return pip_script + def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -402,7 +493,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -410,6 +501,7 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True + def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -421,10 +513,13 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(python_exe), str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org"] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [ + str(python_exe), + str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org", + ] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -432,20 +527,28 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True + def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", "--upgrade", "pip"], + isolated=True, + python_exe=python_exe, + ): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe + ): print(f"❌ Failed to install {pkg}.") return False return True + def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -453,9 +556,11 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} - ) # nosec + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -463,6 +568,7 @@ def get_certifi_path(python_exe=None): pass return None + def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -496,6 +602,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True + def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -507,8 +614,13 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run([str(binary_path), "--help"], env=env, - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(binary_path), "--help"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -523,7 +635,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -531,6 +643,7 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None + def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -542,17 +655,20 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): + if run_pip_command( + ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe + ): method = test_portablemc(python_exe) if method: return method return None + # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -593,12 +709,20 @@ def add_candidate(p): # 4. Common install locations (static paths) for ver in PYTHON_VERSIONS: - num = ver.replace('.', '') + num = ver.replace(".", "") # System-wide installations - for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): + for base in ( + r"C:\Python{}", + r"C:\Program Files\Python{}", + r"C:\Program Files (x86)\Python{}", + ): add_candidate(base.format(num) + "\\python.exe") # User installations - user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" + user_dir = ( + Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + / "Programs" + / f"Python{num}" + ) add_candidate(user_dir / "python.exe") # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") @@ -608,11 +732,13 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec + result = subprocess.run( + [str(p), "--version"], capture_output=True, text=True, timeout=2 + ) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: # Extract version using regex to handle suffixes like "3.11.0rc1" - match = re.search(r'\d+(?:\.\d+)+', combined) + match = re.search(r"\d+(?:\.\d+)+", combined) if match: version_str = match.group() valid.append((version_str, p)) @@ -623,11 +749,12 @@ def add_candidate(p): return None # Sort by version descending (higher version first) - valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best + # --- Launcher functions --- def launch_launcher(method, python_exe=None, extra_env=None): launcher_script = PORTABLEMC_PY @@ -674,6 +801,7 @@ def launch_launcher(method, python_exe=None, extra_env=None): print("⏹️ Interrupted by user.") return True + def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -712,7 +840,7 @@ def run_web_launcher(): for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install {pkg}: {result.stderr}") return False @@ -729,10 +857,11 @@ def run_web_launcher(): extra_env = { "PYTHONUSERBASE": str(launcher_python_dir), "PYTHONNOUSERSITE": "0", - "PATH": os.environ["PATH"] # keep the original PATH + "PATH": os.environ["PATH"], # keep the original PATH } return launch_launcher(method, sys_python, extra_env) + def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") prepare_user_data() @@ -754,7 +883,7 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}" + f"/p:JvmOpts={DEFAULT_JVM_OPTS}", ] print(f"Executing: {' '.join(cmd)}") try: @@ -764,13 +893,18 @@ def run_msbuild_launcher(): print("✅ MSBuild succeeded.") return True else: - print(f"⚠️ MSBuild at {msbuild_path} exited with code {result.returncode}. Trying next candidate.") + print( + f"⚠️ MSBuild at {msbuild_path} exited with code {result.returncode}. Trying next candidate." + ) except Exception as e: - print(f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate.") + print( + f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate." + ) print("❌ All MSBuild candidates failed.") return False + def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -785,14 +919,21 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), "-m", "portablemc", - "--main-dir", ".", - "--output", "human-color", + str(EMBEDDED_PYTHON), + "-m", + "portablemc", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" @@ -842,14 +983,21 @@ def run_cli_launcher(): ensure_junctions() cmd = [ - str(sys_python), "-m", "portablemc", - "--main-dir", ".", - "--output", "human-color", + str(sys_python), + "-m", + "portablemc", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -864,6 +1012,7 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True + def main(): # Display menu print("\n" + "=" * 60) @@ -890,5 +1039,6 @@ def main(): sys.exit(0 if success else 1) + if __name__ == "__main__": main() From e89ee019f57f3cfec9f357ea0233cd9465b5a273 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:17:52 +0000 Subject: [PATCH 50/70] Update portablemc.py --- scripts/portablemc.py | 127 +++++++++++------------------------------- 1 file changed, 33 insertions(+), 94 deletions(-) diff --git a/scripts/portablemc.py b/scripts/portablemc.py index b4956e4..82481a9 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -32,14 +32,12 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") - def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace("&", "&").replace("<", "<").replace(">", ">") - + return s.replace('&', '&').replace('<', '<').replace('>', '>') # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") +VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -58,7 +56,6 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil - PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -71,7 +68,6 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter - ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -927,38 +923,34 @@ def escape_html(s): """ - # --- ROUTES --- -@socketio.on("connect") +@socketio.on('connect') def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f"Client connected: {client_id} (Total: {connected_clients})") - emit("status", {"core": "online", "minecraft": "checking"}) - + print(f'Client connected: {client_id} (Total: {connected_clients})') + emit('status', {'core': 'online', 'minecraft': 'checking'}) -@socketio.on("disconnect") +@socketio.on('disconnect') def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f"Client disconnected: {client_id} (Total: {connected_clients})") + print(f'Client disconnected: {client_id} (Total: {connected_clients})') - -@socketio.on("ping_minecraft") +@socketio.on('ping_minecraft') def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit("minecraft_status", {"online": True}) + emit('minecraft_status', {'online': True}) except Exception: - emit("minecraft_status", {"online": False}) - + emit('minecraft_status', {'online': False}) @app.route("/ping") def ping(): @@ -971,7 +963,6 @@ def ping(): except Exception: return jsonify(online=False), 200 - @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -981,17 +972,15 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): - def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -1000,44 +989,31 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: - def error_gen(): - payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): - def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: - def error_gen(): - payload = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1046,15 +1022,13 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m", + "\x1b[90mPlease close the game before launching again.\x1b[0m" ] - def error_gen(): for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1069,9 +1043,7 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get( - "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") - ) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1127,15 +1099,13 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} - ) + busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == "nt": + if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1151,7 +1121,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo, + startupinfo=startupinfo ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1163,12 +1133,7 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", - } - ) + disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1181,7 +1146,7 @@ def generate(): break continue - raw_line = line.rstrip("\n") + raw_line = line.rstrip('\n') now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1192,23 +1157,18 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert( - safe_line, full=False - ).strip() - payload = json.dumps({"type": "html", "content": html_line}) + html_line = ansi_converter.convert(safe_line, full=False).strip() + payload = json.dumps({'type': 'html', 'content': html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) else: - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if ( - current_file != last_progress - and (now - last_send_time) > update_interval - ): + if current_file != last_progress and (now - last_send_time) > update_interval: yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1222,21 +1182,14 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps( - { - "type": "ansi", - "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps( - {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} - ) + payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) yield f"data: {payload}\n\n" except Exception: pass @@ -1251,18 +1204,8 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", - } - ) - tip_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", - } - ) + ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) + tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1275,12 +1218,10 @@ def generate(): response.call_on_close(closed_event.set) return response - @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) - def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1307,7 +1248,6 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") - def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1315,7 +1255,6 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) - signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": From 5a130298a10c86a66d8feb1acfa5c7007ae8d3fc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Mar 2026 13:18:01 +0000 Subject: [PATCH 51/70] Apply automatic ruff fixes and formatting [skip ci] --- scripts/portablemc.py | 127 +++++++++++++++++++++++++++++++----------- 1 file changed, 94 insertions(+), 33 deletions(-) diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 82481a9..b4956e4 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -32,12 +32,14 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") + def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') + return s.replace("&", "&").replace("<", "<").replace(">", ">") + # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -56,6 +58,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -68,6 +71,7 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -923,34 +927,38 @@ def escape_html(s): """ + # --- ROUTES --- -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) + print(f"Client connected: {client_id} (Total: {connected_clients})") + emit("status", {"core": "online", "minecraft": "checking"}) + -@socketio.on('disconnect') +@socketio.on("disconnect") def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') + print(f"Client disconnected: {client_id} (Total: {connected_clients})") -@socketio.on('ping_minecraft') + +@socketio.on("ping_minecraft") def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit('minecraft_status', {'online': True}) + emit("minecraft_status", {"online": True}) except Exception: - emit('minecraft_status', {'online': False}) + emit("minecraft_status", {"online": False}) + @app.route("/ping") def ping(): @@ -963,6 +971,7 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -972,15 +981,17 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): + def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -989,31 +1000,44 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): + def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", + } + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1022,13 +1046,15 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" + "\x1b[90mPlease close the game before launching again.\x1b[0m", ] + def error_gen(): for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1043,7 +1069,9 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + local_appdata = os.environ.get( + "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") + ) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1099,13 +1127,15 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + busy_payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} + ) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == 'nt': + if os.name == "nt": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1121,7 +1151,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo + startupinfo=startupinfo, ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1133,7 +1163,12 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + disc_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", + } + ) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1146,7 +1181,7 @@ def generate(): break continue - raw_line = line.rstrip('\n') + raw_line = line.rstrip("\n") now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1157,18 +1192,23 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) + html_line = ansi_converter.convert( + safe_line, full=False + ).strip() + payload = json.dumps({"type": "html", "content": html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: + if ( + current_file != last_progress + and (now - last_send_time) > update_interval + ): yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1182,14 +1222,21 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", + } + ) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + payload = json.dumps( + {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} + ) yield f"data: {payload}\n\n" except Exception: pass @@ -1204,8 +1251,18 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + ended_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", + } + ) + tip_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", + } + ) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1218,10 +1275,12 @@ def generate(): response.call_on_close(closed_event.set) return response + @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1248,6 +1307,7 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1255,6 +1315,7 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": From 11e9b9eb2edd2cce9e5dea1289b5a9c80b7e6e98 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:26:47 +0000 Subject: [PATCH 52/70] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ad84678..cd3bc9d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Minecraft-Portable-Web/ ## Requirements -- Windows 7/8/10/11 +- Windows 10/11 - Python 3.11+ (or the script will download an embedded Python 3.14). - (Optional) MSBuild (comes with .NET Framework 4.8) – used for the C# fallback. - (Optional) PowerShell 3.0+ – used as a fallback. From 11481aa373c068d9a80e504218391c0261180c11 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:44:43 +0000 Subject: [PATCH 53/70] Attempt to fix SSL errors --- main.py | 373 ++++++++++++++---------------------------- scripts/Launcher.hta | 33 ++++ scripts/portablemc.py | 129 ++++----------- 3 files changed, 191 insertions(+), 344 deletions(-) create mode 100644 scripts/Launcher.hta diff --git a/main.py b/main.py index 389d284..c9395cf 100644 --- a/main.py +++ b/main.py @@ -20,7 +20,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != "windows": +if platform.system().lower() != 'windows': print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -34,13 +34,9 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( - "1", - "true", - "yes", -) +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -64,36 +60,35 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - "x86_64": "x86_64", - "amd64": "x86_64", - "i686": "i686", - "i386": "i686", - "aarch64": "aarch64", - "arm64": "aarch64", - "armv7l": "arm-gnueabihf", - "arm": "arm-gnueabihf", + 'x86_64': 'x86_64', + 'amd64': 'x86_64', + 'i686': 'i686', + 'i386': 'i686', + 'aarch64': 'aarch64', + 'arm64': 'aarch64', + 'armv7l': 'arm-gnueabihf', + 'arm': 'arm-gnueabihf', } OS_MAP = { - "windows": "windows", - "linux": "linux", - "darwin": "macos", + 'windows': 'windows', + 'linux': 'linux', + 'darwin': 'macos', } - def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, "x86_64") - if os_name == "macos": - arch = "aarch64" if arch == "aarch64" else "x86_64" - if os_name == "linux" and arch not in ("arm-gnueabihf",): - arch += "-gnu" + arch = ARCH_MAP.get(MACHINE, 'x86_64') + if os_name == 'macos': + arch = 'aarch64' if arch == 'aarch64' else 'x86_64' + if os_name == 'linux' and arch not in ('arm-gnueabihf',): + arch += '-gnu' base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == "windows": + if os_name == 'windows': ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -101,7 +96,6 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename - # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -133,7 +127,6 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") - # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -143,7 +136,6 @@ def is_junction(path): except OSError: return False - def create_junction(source, target): """ Create a junction from source to target. @@ -155,9 +147,7 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print( - f"⚠️ Source and target are the same ({source_path}); skipping junction creation." - ) + print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") # Still ensure the target directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -180,20 +170,15 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, - capture_output=True, - text=True, + check=True, capture_output=True, text=True ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print( - f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" - ) + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") target_path.mkdir(parents=True, exist_ok=True) return False - def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" appdata = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) @@ -203,18 +188,14 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") - # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print( - "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." - ) + print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") return ssl._create_unverified_context() return None - def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -227,7 +208,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, "wb") as f: + with open(dest_path, 'wb') as f: f.write(response.read()) return True except Exception as e2: @@ -235,9 +216,9 @@ def download_file(url, dest_path): return False else: print("❌ Download failed and insecure SSL is disabled.") + print("⚠️ If you are in a restricted network, set ALLOW_INSECURE_SSL=true and try again.") return False - # --- Helper functions --- def find_msbuild_candidates(): """Return a list of candidate MSBuild.exe paths sorted by priority (highest first).""" @@ -247,21 +228,10 @@ def find_msbuild_candidates(): vswhere = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" if os.path.isfile(vswhere): try: - result = subprocess.run( - [ - vswhere, - "-latest", - "-products", - "*", - "-find", - "MSBuild\\**\\Bin\\MSBuild.exe", - ], - capture_output=True, - text=True, - timeout=5, - ) + result = subprocess.run([vswhere, "-latest", "-products", "*", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"], + capture_output=True, text=True, timeout=5) if result.returncode == 0: - for line in result.stdout.strip().split("\n"): + for line in result.stdout.strip().split('\n'): if line and os.path.isfile(line): candidates.append((line, 100)) # highest priority except Exception: @@ -270,76 +240,33 @@ def find_msbuild_candidates(): # 2. Hard‑coded candidates with descending priorities hardcoded = [ # Visual Studio 2026 (v18.0) - ( - r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), + (r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 99), + # Visual Studio 2022 (v17.0) - ( - r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), + (r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 90), + # Visual Studio 2019 (v16.0) - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 80), + # Visual Studio 2017 (v15.0) - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", 70), + # Standalone Build Tools (v14.0, v12.0) (r"C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe", 60), (r"C:\Program Files (x86)\MSBuild\12.0\Bin\MSBuild.exe", 50), + # .NET Framework (64‑bit preferred, then 32‑bit) (r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe", 40), (r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe", 30), @@ -359,7 +286,6 @@ def find_msbuild_candidates(): sorted_candidates = sorted(unique.items(), key=lambda x: x[1], reverse=True) return [path for path, _ in sorted_candidates] - def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" BASE_DIR.mkdir(parents=True, exist_ok=True) @@ -379,7 +305,6 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True - def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -398,16 +323,11 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True - def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run( - [str(EMBEDDED_PYTHON), "--version"], - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -421,7 +341,6 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False - def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -438,10 +357,8 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) # nosec + env=env_check, capture_output=True, text=True + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -450,20 +367,14 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True - def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" print("📦 Installing portablemc via pip...") - if not run_pip_command( - ["-m", "pip", "install", "portablemc"], - isolated=True, - python_exe=EMBEDDED_PYTHON, - ): + if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) - def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -475,7 +386,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, "wb") as out_file: + with open(pip_script, 'wb') as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -483,7 +394,6 @@ def download_get_pip(): return None return pip_script - def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -493,7 +403,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -501,7 +411,6 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True - def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -513,13 +422,10 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [ - str(python_exe), - str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org", - ] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [str(python_exe), str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org"] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -527,28 +433,20 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True - def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command( - ["-m", "pip", "install", "--upgrade", "pip"], - isolated=True, - python_exe=python_exe, - ): + if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command( - ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe - ): + if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): print(f"❌ Failed to install {pkg}.") return False return True - def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -556,11 +454,9 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, - text=True, - check=True, - env={"PYTHONNOUSERSITE": "1"}, - ) # nosec + capture_output=True, text=True, check=True, + env={"PYTHONNOUSERSITE": "1"} + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -568,7 +464,6 @@ def get_certifi_path(python_exe=None): pass return None - def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -602,7 +497,6 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True - def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -614,13 +508,8 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run( - [str(binary_path), "--help"], - env=env, - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(binary_path), "--help"], env=env, + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -635,7 +524,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -643,7 +532,6 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None - def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -655,33 +543,30 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command( - ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe - ): + if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): method = test_portablemc(python_exe) if method: return method return None - # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() def add_candidate(p): p = Path(p).resolve() - if p.exists() and p not in seen: + if p.exists() and p not in seen and not str(p).startswith(str(BASE_DIR)): seen.add(p) candidates.append(p) # 1. Current interpreter (if it's not the embedded one) current = Path(sys.executable) - if current != EMBEDDED_PYTHON: + if current != EMBEDDED_PYTHON and not str(p).startswith(str(BASE_DIR)): add_candidate(current) # 2. Search PATH (most likely to be user's preferred Python) @@ -709,20 +594,12 @@ def add_candidate(p): # 4. Common install locations (static paths) for ver in PYTHON_VERSIONS: - num = ver.replace(".", "") + num = ver.replace('.', '') # System-wide installations - for base in ( - r"C:\Python{}", - r"C:\Program Files\Python{}", - r"C:\Program Files (x86)\Python{}", - ): + for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): add_candidate(base.format(num) + "\\python.exe") # User installations - user_dir = ( - Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) - / "Programs" - / f"Python{num}" - ) + user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" add_candidate(user_dir / "python.exe") # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") @@ -732,13 +609,11 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run( - [str(p), "--version"], capture_output=True, text=True, timeout=2 - ) # nosec + result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: # Extract version using regex to handle suffixes like "3.11.0rc1" - match = re.search(r"\d+(?:\.\d+)+", combined) + match = re.search(r'\d+(?:\.\d+)+', combined) if match: version_str = match.group() valid.append((version_str, p)) @@ -749,12 +624,11 @@ def add_candidate(p): return None # Sort by version descending (higher version first) - valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best - # --- Launcher functions --- def launch_launcher(method, python_exe=None, extra_env=None): launcher_script = PORTABLEMC_PY @@ -801,7 +675,6 @@ def launch_launcher(method, python_exe=None, extra_env=None): print("⏹️ Interrupted by user.") return True - def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -840,7 +713,7 @@ def run_web_launcher(): for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install {pkg}: {result.stderr}") return False @@ -857,11 +730,10 @@ def run_web_launcher(): extra_env = { "PYTHONUSERBASE": str(launcher_python_dir), "PYTHONNOUSERSITE": "0", - "PATH": os.environ["PATH"], # keep the original PATH + "PATH": os.environ["PATH"] # keep the original PATH } return launch_launcher(method, sys_python, extra_env) - def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") prepare_user_data() @@ -883,7 +755,7 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}", + f"/p:JvmOpts={DEFAULT_JVM_OPTS}" ] print(f"Executing: {' '.join(cmd)}") try: @@ -893,18 +765,13 @@ def run_msbuild_launcher(): print("✅ MSBuild succeeded.") return True else: - print( - f"⚠️ MSBuild at {msbuild_path} exited with code {result.returncode}. Trying next candidate." - ) + print(f"⚠️ MSBuild at {msbuild_path} exited with code {result.returncode}. Trying next candidate.") except Exception as e: - print( - f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate." - ) + print(f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate.") print("❌ All MSBuild candidates failed.") return False - def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -912,34 +779,45 @@ def run_cli_launcher(): # Try embedded Python first if setup_embedded_python(): + # Install portablemc (and certifi for SSL) into embedded Python method = install_portablemc_via_embedded() if not method: return False + + # Install certifi to get CA bundle + print("📦 Installing certifi for SSL support...") + if not run_pip_command(["-m", "pip", "install", "certifi"], isolated=True, python_exe=EMBEDDED_PYTHON): + print("⚠️ Failed to install certifi; SSL errors may occur.") + else: + print("✅ certifi installed.") + + # Get certifi CA bundle path + cert_path = get_certifi_path(EMBEDDED_PYTHON) + + # Create junctions (if not already done) ensure_junctions() # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), - "-m", - "portablemc", - "--main-dir", - ".", - "--output", - "human-color", + str(EMBEDDED_PYTHON), "-m", "portablemc", + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] + env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" env["LAUNCHER_ROOT"] = str(ROOT_DIR) + if cert_path: + env["SSL_CERT_FILE"] = cert_path + env["REQUESTS_CA_BUNDLE"] = cert_path + print(f"🚀 Launching: {' '.join(cmd)}") try: subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec @@ -967,9 +845,9 @@ def run_cli_launcher(): env["PYTHONNOUSERSITE"] = "0" env["PYTHONPATH"] = "" - # Install portablemc with system Python + # Install portablemc and certifi with system Python print("📦 Installing portablemc with system Python...") - cmd = [str(sys_python), "-m", "pip", "install", "--user", "portablemc"] + cmd = [str(sys_python), "-m", "pip", "install", "--user", "portablemc", "certifi"] result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install portablemc: {result.stderr}") @@ -980,27 +858,26 @@ def run_cli_launcher(): print("❌ portablemc not available after installation.") return False + cert_path = get_certifi_path(sys_python) # certifi should now be installed + ensure_junctions() cmd = [ - str(sys_python), - "-m", - "portablemc", - "--main-dir", - ".", - "--output", - "human-color", + str(sys_python), "-m", "portablemc", + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, + "--jvm-args", DEFAULT_JVM_OPTS, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) + if cert_path: + env["SSL_CERT_FILE"] = cert_path + env["REQUESTS_CA_BUNDLE"] = cert_path + print(f"🚀 Launching: {' '.join(cmd)}") try: subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec @@ -1012,7 +889,6 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True - def main(): # Display menu print("\n" + "=" * 60) @@ -1039,6 +915,5 @@ def main(): sys.exit(0 if success else 1) - if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/scripts/Launcher.hta b/scripts/Launcher.hta new file mode 100644 index 0000000..f6e0f0c --- /dev/null +++ b/scripts/Launcher.hta @@ -0,0 +1,33 @@ + + + +PortableMC Launcher Test + + + + + + \ No newline at end of file diff --git a/scripts/portablemc.py b/scripts/portablemc.py index b4956e4..6ef7c60 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -32,14 +32,12 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") - def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace("&", "&").replace("<", "<").replace(">", ">") - + return s.replace('&', '&').replace('<', '<').replace('>', '>') # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") +VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -58,7 +56,6 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil - PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -71,7 +68,6 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter - ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -927,38 +923,34 @@ def escape_html(s): """ - # --- ROUTES --- -@socketio.on("connect") +@socketio.on('connect') def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f"Client connected: {client_id} (Total: {connected_clients})") - emit("status", {"core": "online", "minecraft": "checking"}) - + print(f'Client connected: {client_id} (Total: {connected_clients})') + emit('status', {'core': 'online', 'minecraft': 'checking'}) -@socketio.on("disconnect") +@socketio.on('disconnect') def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f"Client disconnected: {client_id} (Total: {connected_clients})") + print(f'Client disconnected: {client_id} (Total: {connected_clients})') - -@socketio.on("ping_minecraft") +@socketio.on('ping_minecraft') def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit("minecraft_status", {"online": True}) + emit('minecraft_status', {'online': True}) except Exception: - emit("minecraft_status", {"online": False}) - + emit('minecraft_status', {'online': False}) @app.route("/ping") def ping(): @@ -971,7 +963,6 @@ def ping(): except Exception: return jsonify(online=False), 200 - @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -981,17 +972,15 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): - def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -1000,44 +989,31 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: - def error_gen(): - payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): - def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: - def error_gen(): - payload = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1046,15 +1022,13 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m", + "\x1b[90mPlease close the game before launching again.\x1b[0m" ] - def error_gen(): for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1069,9 +1043,7 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get( - "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") - ) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1127,15 +1099,13 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} - ) + busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == "nt": + if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1151,7 +1121,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo, + startupinfo=startupinfo ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1163,12 +1133,7 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", - } - ) + disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1181,7 +1146,7 @@ def generate(): break continue - raw_line = line.rstrip("\n") + raw_line = line.rstrip('\n') now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1192,23 +1157,18 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert( - safe_line, full=False - ).strip() - payload = json.dumps({"type": "html", "content": html_line}) + html_line = ansi_converter.convert(safe_line, full=False).strip() + payload = json.dumps({'type': 'html', 'content': html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) else: - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if ( - current_file != last_progress - and (now - last_send_time) > update_interval - ): + if current_file != last_progress and (now - last_send_time) > update_interval: yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1222,21 +1182,14 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps( - { - "type": "ansi", - "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps( - {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} - ) + payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) yield f"data: {payload}\n\n" except Exception: pass @@ -1251,18 +1204,8 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", - } - ) - tip_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", - } - ) + ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) + tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1275,12 +1218,10 @@ def generate(): response.call_on_close(closed_event.set) return response - @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) - def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1307,7 +1248,6 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") - def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1315,11 +1255,10 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) - signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) + graceful_shutdown(None, None) \ No newline at end of file From 57f0ff8a1d1af771324c5f081acc60bb1992a993 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:48:05 +0000 Subject: [PATCH 54/70] Fix typo --- main.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/main.py b/main.py index c9395cf..8579889 100644 --- a/main.py +++ b/main.py @@ -564,17 +564,17 @@ def add_candidate(p): seen.add(p) candidates.append(p) - # 1. Current interpreter (if it's not the embedded one) + # 1. Current interpreter (if it's not embedded) current = Path(sys.executable) - if current != EMBEDDED_PYTHON and not str(p).startswith(str(BASE_DIR)): + if current != EMBEDDED_PYTHON and not str(current).startswith(str(BASE_DIR)): add_candidate(current) - # 2. Search PATH (most likely to be user's preferred Python) + # 2. Search PATH for dir in os.environ.get("PATH", "").split(os.pathsep): add_candidate(Path(dir) / "python.exe") add_candidate(Path(dir) / "python3.exe") - # 3. Registry (both HKCU and HKLM) + # 3. Registry for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): try: key = winreg.OpenKey(hive, r"Software\Python\PythonCore") @@ -592,27 +592,24 @@ def add_candidate(p): break winreg.CloseKey(key) - # 4. Common install locations (static paths) + # 4. Common install locations for ver in PYTHON_VERSIONS: num = ver.replace('.', '') - # System-wide installations for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): add_candidate(base.format(num) + "\\python.exe") - # User installations user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" add_candidate(user_dir / "python.exe") - # Sysnative and System32 (often contain a 64-bit Python from WOW64) add_candidate(r"C:\Windows\Sysnative\python.exe") add_candidate(r"C:\Windows\System32\python.exe") - # Now verify each candidate and extract version + # Verify candidates + import re valid = [] for p in candidates: try: result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: - # Extract version using regex to handle suffixes like "3.11.0rc1" match = re.search(r'\d+(?:\.\d+)+', combined) if match: version_str = match.group() @@ -623,7 +620,6 @@ def add_candidate(p): if not valid: return None - # Sort by version descending (higher version first) valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") From b7af6c88bdb8e71eda015d51818d64274568ae28 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:49:47 +0000 Subject: [PATCH 55/70] Remove multiple imports --- main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/main.py b/main.py index 8579889..26e6c5c 100644 --- a/main.py +++ b/main.py @@ -603,7 +603,6 @@ def add_candidate(p): add_candidate(r"C:\Windows\System32\python.exe") # Verify candidates - import re valid = [] for p in candidates: try: @@ -912,4 +911,4 @@ def main(): sys.exit(0 if success else 1) if __name__ == "__main__": - main() \ No newline at end of file + main() From 991e33c2602c3f0a6fcc48c23a444d8620dff971 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Mar 2026 13:49:57 +0000 Subject: [PATCH 56/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 348 ++++++++++++++++++++++++++++++------------ scripts/portablemc.py | 129 +++++++++++----- 2 files changed, 347 insertions(+), 130 deletions(-) diff --git a/main.py b/main.py index 26e6c5c..383da90 100644 --- a/main.py +++ b/main.py @@ -20,7 +20,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != 'windows': +if platform.system().lower() != "windows": print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -34,9 +34,13 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( + "1", + "true", + "yes", +) # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -60,35 +64,36 @@ MACHINE = platform.machine().lower() ARCH_MAP = { - 'x86_64': 'x86_64', - 'amd64': 'x86_64', - 'i686': 'i686', - 'i386': 'i686', - 'aarch64': 'aarch64', - 'arm64': 'aarch64', - 'armv7l': 'arm-gnueabihf', - 'arm': 'arm-gnueabihf', + "x86_64": "x86_64", + "amd64": "x86_64", + "i686": "i686", + "i386": "i686", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "arm-gnueabihf", + "arm": "arm-gnueabihf", } OS_MAP = { - 'windows': 'windows', - 'linux': 'linux', - 'darwin': 'macos', + "windows": "windows", + "linux": "linux", + "darwin": "macos", } + def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, 'x86_64') - if os_name == 'macos': - arch = 'aarch64' if arch == 'aarch64' else 'x86_64' - if os_name == 'linux' and arch not in ('arm-gnueabihf',): - arch += '-gnu' + arch = ARCH_MAP.get(MACHINE, "x86_64") + if os_name == "macos": + arch = "aarch64" if arch == "aarch64" else "x86_64" + if os_name == "linux" and arch not in ("arm-gnueabihf",): + arch += "-gnu" base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == 'windows': + if os_name == "windows": ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -96,6 +101,7 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename + # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -127,6 +133,7 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -136,6 +143,7 @@ def is_junction(path): except OSError: return False + def create_junction(source, target): """ Create a junction from source to target. @@ -147,7 +155,9 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") + print( + f"⚠️ Source and target are the same ({source_path}); skipping junction creation." + ) # Still ensure the target directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -170,15 +180,20 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True + check=True, + capture_output=True, + text=True, ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" appdata = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) @@ -188,14 +203,18 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") + print( + "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." + ) return ssl._create_unverified_context() return None + def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -208,7 +227,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, 'wb') as f: + with open(dest_path, "wb") as f: f.write(response.read()) return True except Exception as e2: @@ -216,9 +235,12 @@ def download_file(url, dest_path): return False else: print("❌ Download failed and insecure SSL is disabled.") - print("⚠️ If you are in a restricted network, set ALLOW_INSECURE_SSL=true and try again.") + print( + "⚠️ If you are in a restricted network, set ALLOW_INSECURE_SSL=true and try again." + ) return False + # --- Helper functions --- def find_msbuild_candidates(): """Return a list of candidate MSBuild.exe paths sorted by priority (highest first).""" @@ -228,10 +250,21 @@ def find_msbuild_candidates(): vswhere = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" if os.path.isfile(vswhere): try: - result = subprocess.run([vswhere, "-latest", "-products", "*", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"], - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [ + vswhere, + "-latest", + "-products", + "*", + "-find", + "MSBuild\\**\\Bin\\MSBuild.exe", + ], + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: - for line in result.stdout.strip().split('\n'): + for line in result.stdout.strip().split("\n"): if line and os.path.isfile(line): candidates.append((line, 100)) # highest priority except Exception: @@ -240,33 +273,76 @@ def find_msbuild_candidates(): # 2. Hard‑coded candidates with descending priorities hardcoded = [ # Visual Studio 2026 (v18.0) - (r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 99), - + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), # Visual Studio 2022 (v17.0) - (r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 90), - + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), # Visual Studio 2019 (v16.0) - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 80), - + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), # Visual Studio 2017 (v15.0) - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", 70), - + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), # Standalone Build Tools (v14.0, v12.0) (r"C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe", 60), (r"C:\Program Files (x86)\MSBuild\12.0\Bin\MSBuild.exe", 50), - # .NET Framework (64‑bit preferred, then 32‑bit) (r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe", 40), (r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe", 30), @@ -286,6 +362,7 @@ def find_msbuild_candidates(): sorted_candidates = sorted(unique.items(), key=lambda x: x[1], reverse=True) return [path for path, _ in sorted_candidates] + def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" BASE_DIR.mkdir(parents=True, exist_ok=True) @@ -305,6 +382,7 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True + def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -323,11 +401,16 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True + def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(EMBEDDED_PYTHON), "--version"], + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -341,6 +424,7 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False + def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -357,8 +441,10 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True - ) # nosec + env=env_check, + capture_output=True, + text=True, + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -367,14 +453,20 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True + def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" print("📦 Installing portablemc via pip...") - if not run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not run_pip_command( + ["-m", "pip", "install", "portablemc"], + isolated=True, + python_exe=EMBEDDED_PYTHON, + ): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) + def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -386,7 +478,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, 'wb') as out_file: + with open(pip_script, "wb") as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -394,6 +486,7 @@ def download_get_pip(): return None return pip_script + def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -403,7 +496,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -411,6 +504,7 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True + def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -422,10 +516,13 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(python_exe), str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org"] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [ + str(python_exe), + str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org", + ] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -433,20 +530,28 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True + def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command(["-m", "pip", "install", "--upgrade", "pip"], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", "--upgrade", "pip"], + isolated=True, + python_exe=python_exe, + ): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command(["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe): + if not run_pip_command( + ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe + ): print(f"❌ Failed to install {pkg}.") return False return True + def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -454,9 +559,11 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} - ) # nosec + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -464,6 +571,7 @@ def get_certifi_path(python_exe=None): pass return None + def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -497,6 +605,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True + def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -508,8 +617,13 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run([str(binary_path), "--help"], env=env, - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(binary_path), "--help"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -524,7 +638,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -532,6 +646,7 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None + def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -543,17 +658,20 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc via pip...") - if run_pip_command(["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe): + if run_pip_command( + ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe + ): method = test_portablemc(python_exe) if method: return method return None + # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -594,10 +712,18 @@ def add_candidate(p): # 4. Common install locations for ver in PYTHON_VERSIONS: - num = ver.replace('.', '') - for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): + num = ver.replace(".", "") + for base in ( + r"C:\Python{}", + r"C:\Program Files\Python{}", + r"C:\Program Files (x86)\Python{}", + ): add_candidate(base.format(num) + "\\python.exe") - user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" + user_dir = ( + Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + / "Programs" + / f"Python{num}" + ) add_candidate(user_dir / "python.exe") add_candidate(r"C:\Windows\Sysnative\python.exe") add_candidate(r"C:\Windows\System32\python.exe") @@ -606,10 +732,12 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec + result = subprocess.run( + [str(p), "--version"], capture_output=True, text=True, timeout=2 + ) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: - match = re.search(r'\d+(?:\.\d+)+', combined) + match = re.search(r"\d+(?:\.\d+)+", combined) if match: version_str = match.group() valid.append((version_str, p)) @@ -619,11 +747,12 @@ def add_candidate(p): if not valid: return None - valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best + # --- Launcher functions --- def launch_launcher(method, python_exe=None, extra_env=None): launcher_script = PORTABLEMC_PY @@ -670,6 +799,7 @@ def launch_launcher(method, python_exe=None, extra_env=None): print("⏹️ Interrupted by user.") return True + def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -708,7 +838,7 @@ def run_web_launcher(): for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Failed to install {pkg}: {result.stderr}") return False @@ -725,10 +855,11 @@ def run_web_launcher(): extra_env = { "PYTHONUSERBASE": str(launcher_python_dir), "PYTHONNOUSERSITE": "0", - "PATH": os.environ["PATH"] # keep the original PATH + "PATH": os.environ["PATH"], # keep the original PATH } return launch_launcher(method, sys_python, extra_env) + def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") prepare_user_data() @@ -750,7 +881,7 @@ def run_msbuild_launcher(): str(TARGETS_FILE), f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", - f"/p:JvmOpts={DEFAULT_JVM_OPTS}" + f"/p:JvmOpts={DEFAULT_JVM_OPTS}", ] print(f"Executing: {' '.join(cmd)}") try: @@ -760,13 +891,18 @@ def run_msbuild_launcher(): print("✅ MSBuild succeeded.") return True else: - print(f"⚠️ MSBuild at {msbuild_path} exited with code {result.returncode}. Trying next candidate.") + print( + f"⚠️ MSBuild at {msbuild_path} exited with code {result.returncode}. Trying next candidate." + ) except Exception as e: - print(f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate.") + print( + f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate." + ) print("❌ All MSBuild candidates failed.") return False + def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") @@ -781,7 +917,11 @@ def run_cli_launcher(): # Install certifi to get CA bundle print("📦 Installing certifi for SSL support...") - if not run_pip_command(["-m", "pip", "install", "certifi"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not run_pip_command( + ["-m", "pip", "install", "certifi"], + isolated=True, + python_exe=EMBEDDED_PYTHON, + ): print("⚠️ Failed to install certifi; SSL errors may occur.") else: print("✅ certifi installed.") @@ -794,14 +934,21 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) cmd = [ - str(EMBEDDED_PYTHON), "-m", "portablemc", - "--main-dir", ".", - "--output", "human-color", + str(EMBEDDED_PYTHON), + "-m", + "portablemc", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env = os.environ.copy() @@ -858,14 +1005,21 @@ def run_cli_launcher(): ensure_junctions() cmd = [ - str(sys_python), "-m", "portablemc", - "--main-dir", ".", - "--output", "human-color", + str(sys_python), + "-m", + "portablemc", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, - "--jvm-args", DEFAULT_JVM_OPTS, + "--server", + DEFAULT_SERVER_IP, + "--jvm-args", + DEFAULT_JVM_OPTS, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) @@ -884,6 +1038,7 @@ def run_cli_launcher(): print("⏹️ Interrupted by user.") return True + def main(): # Display menu print("\n" + "=" * 60) @@ -910,5 +1065,6 @@ def main(): sys.exit(0 if success else 1) + if __name__ == "__main__": main() diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 6ef7c60..b4956e4 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -32,12 +32,14 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") + def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') + return s.replace("&", "&").replace("<", "<").replace(">", ">") + # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -56,6 +58,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -68,6 +71,7 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -923,34 +927,38 @@ def escape_html(s): """ + # --- ROUTES --- -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) + print(f"Client connected: {client_id} (Total: {connected_clients})") + emit("status", {"core": "online", "minecraft": "checking"}) + -@socketio.on('disconnect') +@socketio.on("disconnect") def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') + print(f"Client disconnected: {client_id} (Total: {connected_clients})") -@socketio.on('ping_minecraft') + +@socketio.on("ping_minecraft") def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit('minecraft_status', {'online': True}) + emit("minecraft_status", {"online": True}) except Exception: - emit('minecraft_status', {'online': False}) + emit("minecraft_status", {"online": False}) + @app.route("/ping") def ping(): @@ -963,6 +971,7 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -972,15 +981,17 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): + def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -989,31 +1000,44 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): + def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", + } + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1022,13 +1046,15 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" + "\x1b[90mPlease close the game before launching again.\x1b[0m", ] + def error_gen(): for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1043,7 +1069,9 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + local_appdata = os.environ.get( + "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") + ) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1099,13 +1127,15 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + busy_payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} + ) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == 'nt': + if os.name == "nt": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1121,7 +1151,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo + startupinfo=startupinfo, ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1133,7 +1163,12 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + disc_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", + } + ) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1146,7 +1181,7 @@ def generate(): break continue - raw_line = line.rstrip('\n') + raw_line = line.rstrip("\n") now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1157,18 +1192,23 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) + html_line = ansi_converter.convert( + safe_line, full=False + ).strip() + payload = json.dumps({"type": "html", "content": html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: + if ( + current_file != last_progress + and (now - last_send_time) > update_interval + ): yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1182,14 +1222,21 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", + } + ) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + payload = json.dumps( + {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} + ) yield f"data: {payload}\n\n" except Exception: pass @@ -1204,8 +1251,18 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + ended_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", + } + ) + tip_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", + } + ) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1218,10 +1275,12 @@ def generate(): response.call_on_close(closed_event.set) return response + @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1248,6 +1307,7 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1255,10 +1315,11 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) \ No newline at end of file + graceful_shutdown(None, None) From ce65977e49da20b5a5e0407b113104cdfe66e392 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:04:57 +0100 Subject: [PATCH 57/70] Updated code with cursor See pull request for more details --- main.py | 894 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 622 insertions(+), 272 deletions(-) diff --git a/main.py b/main.py index 383da90..61cd54c 100644 --- a/main.py +++ b/main.py @@ -17,15 +17,21 @@ import ssl import re import winreg +import datetime +import traceback +import json from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != "windows": +if platform.system().lower() != 'windows': print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) # --- Configuration --- -# Use AppData for all downloads and runtime files +ROOT_DIR = Path(__file__).parent +PROJECT_LOGS_DIR = ROOT_DIR / "logs" +PROJECT_LOGS_DIR.mkdir(parents=True, exist_ok=True) + APPDATA = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) BASE_DIR = APPDATA / "PortableMC" EMBEDDED_DIR = BASE_DIR / "python" @@ -34,13 +40,9 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( - "1", - "true", - "yes", -) +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -48,7 +50,6 @@ DEFAULT_JVM_OPTS = "-Xmx3G -Xms3G" # Paths -ROOT_DIR = Path(__file__).parent SCRIPTS_DIR = ROOT_DIR / "Scripts" SCRIPTS_DIR.mkdir(exist_ok=True) MSBUILD_PATH = r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe" @@ -58,42 +59,225 @@ LAUNCHER_VBS = SCRIPTS_DIR / "Launcher.vbs" LAUNCHER_PS1 = SCRIPTS_DIR / "Launcher.ps1" PORTABLEMC_PY = SCRIPTS_DIR / "portablemc.py" +CONFIG_PATH = ROOT_DIR / "launcher_config.json" +LAUNCHER_STATE = {} +ACTIVE_TRUSTED_DIR = None + +DEFAULT_CONFIG = { + "schema_version": 1, + "success": False, + "last_error": "", + "last_run_mode": "", + "last_known_working_directory": str(ROOT_DIR), + "last_base_dir": str(BASE_DIR), + "trusted_dir": "", + "trusted_dir_candidates": [], + "trusted_dir_probes": {}, + "config_sync_mode": "copy", + "installer_backend": "", + "managed_links": {}, + "allowlist_trusted_dirs": [], +} + +def _safe_json_dump(path, payload): + try: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, sort_keys=True) + return True + except Exception: + return False + +def _load_json_file(path): + try: + if not path.exists(): + return {} + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + +def _get_builtin_trusted_candidates(): + user_profile = Path(os.environ.get("USERPROFILE", Path.home())) + local_app = Path(os.environ.get("LOCALAPPDATA", user_profile / "AppData/Local")) + roaming_app = Path(os.environ.get("APPDATA", user_profile / "AppData/Roaming")) + temp_dir = Path(os.environ.get("TEMP", local_app / "Temp")) + program_data = Path(os.environ.get("ProgramData", r"C:\ProgramData")) + public_profile = Path(os.environ.get("PUBLIC", r"C:\Users\Public")) + return [ + local_app / "PortableMC", + roaming_app / "PortableMC", + user_profile / ".portablemc", + user_profile / "Documents" / "PortableMC", + public_profile / "Documents" / "PortableMC", + program_data / "PortableMC", + temp_dir / "PortableMC", + ] + +def _probe_directory_access(path): + result = { + "exists": False, + "create_ok": False, + "write_ok": False, + "read_ok": False, + "execute_ok": False, + "error": "", + } + try: + path.mkdir(parents=True, exist_ok=True) + result["exists"] = path.exists() + result["create_ok"] = True + except Exception as exc: + result["error"] = f"mkdir failed: {exc}" + return result + + probe_file = path / ".pmc_probe" + try: + with open(probe_file, "w", encoding="utf-8") as f: + f.write("probe") + result["write_ok"] = True + except Exception as exc: + result["error"] = f"write failed: {exc}" + return result + + try: + _ = probe_file.read_text(encoding="utf-8") + result["read_ok"] = True + except Exception as exc: + result["error"] = f"read failed: {exc}" + + try: + proc = subprocess.run(["cmd", "/c", "cd"], cwd=path, capture_output=True, text=True, timeout=3) # nosec + result["execute_ok"] = proc.returncode == 0 + except Exception as exc: + result["error"] = f"execute probe failed: {exc}" + finally: + try: + probe_file.unlink(missing_ok=True) + except Exception: + pass + + return result + +def _resolve_trusted_dir(config): + allowlist = config.get("allowlist_trusted_dirs", []) + candidates = _get_builtin_trusted_candidates() + for item in allowlist: + try: + p = Path(item) + if p not in candidates: + candidates.insert(0, p) + except Exception: + continue + + probes = {} + selected = None + for candidate in candidates: + key = str(candidate) + try: + probe = _probe_directory_access(candidate) + probes[key] = probe + if selected is None and all(probe.get(k) for k in ("create_ok", "write_ok", "read_ok", "execute_ok")): + selected = candidate + except Exception as exc: + probes[key] = {"error": f"{type(exc).__name__}: {exc}"} + + if selected is None: + selected = BASE_DIR + return selected, candidates, probes + +def _sync_config_to_trusted(config_path, trusted_dir): + trusted_cfg = trusted_dir / "launcher_config.json" + sync_mode = "copy" + try: + trusted_cfg.parent.mkdir(parents=True, exist_ok=True) + if trusted_cfg.exists() or trusted_cfg.is_symlink(): + try: + if trusted_cfg.is_dir(): + shutil.rmtree(trusted_cfg, ignore_errors=True) + else: + trusted_cfg.unlink(missing_ok=True) + except Exception: + pass + os.symlink(str(config_path), str(trusted_cfg)) + sync_mode = "symlink" + except Exception: + try: + shutil.copy2(config_path, trusted_cfg) + sync_mode = "copy" + except Exception: + sync_mode = "disabled" + return trusted_cfg, sync_mode + +def _set_runtime_base_dir(new_base_dir): + global BASE_DIR, EMBEDDED_DIR, EMBEDDED_PYTHON, PORTABLEMC_BIN_DIR + BASE_DIR = Path(new_base_dir) + EMBEDDED_DIR = BASE_DIR / "python" + EMBEDDED_PYTHON = EMBEDDED_DIR / "python.exe" + PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" + +def update_launcher_state(**updates): + LAUNCHER_STATE.update(updates) + _safe_json_dump(CONFIG_PATH, LAUNCHER_STATE) + +def initialize_runtime_configuration(): + global LAUNCHER_STATE, ACTIVE_TRUSTED_DIR + loaded = _load_json_file(CONFIG_PATH) + merged = dict(DEFAULT_CONFIG) + merged.update({k: v for k, v in loaded.items() if k in merged}) + + selected_dir, candidate_list, probes = _resolve_trusted_dir(merged) + ACTIVE_TRUSTED_DIR = selected_dir + _set_runtime_base_dir(selected_dir) + + merged["trusted_dir"] = str(selected_dir) + merged["trusted_dir_candidates"] = [str(x) for x in candidate_list] + merged["trusted_dir_probes"] = probes + merged["last_base_dir"] = str(BASE_DIR) + merged["last_known_working_directory"] = str(ROOT_DIR) + LAUNCHER_STATE = merged + + _safe_json_dump(CONFIG_PATH, LAUNCHER_STATE) + trusted_cfg, sync_mode = _sync_config_to_trusted(CONFIG_PATH, ACTIVE_TRUSTED_DIR) + LAUNCHER_STATE["trusted_config_path"] = str(trusted_cfg) + LAUNCHER_STATE["config_sync_mode"] = sync_mode + _safe_json_dump(CONFIG_PATH, LAUNCHER_STATE) # --- OS and architecture detection (for portablemc binary) --- SYSTEM = platform.system().lower() MACHINE = platform.machine().lower() ARCH_MAP = { - "x86_64": "x86_64", - "amd64": "x86_64", - "i686": "i686", - "i386": "i686", - "aarch64": "aarch64", - "arm64": "aarch64", - "armv7l": "arm-gnueabihf", - "arm": "arm-gnueabihf", + 'x86_64': 'x86_64', + 'amd64': 'x86_64', + 'i686': 'i686', + 'i386': 'i686', + 'aarch64': 'aarch64', + 'arm64': 'aarch64', + 'armv7l': 'arm-gnueabihf', + 'arm': 'arm-gnueabihf', } OS_MAP = { - "windows": "windows", - "linux": "linux", - "darwin": "macos", + 'windows': 'windows', + 'linux': 'linux', + 'darwin': 'macos', } - def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, "x86_64") - if os_name == "macos": - arch = "aarch64" if arch == "aarch64" else "x86_64" - if os_name == "linux" and arch not in ("arm-gnueabihf",): - arch += "-gnu" + arch = ARCH_MAP.get(MACHINE, 'x86_64') + if os_name == 'macos': + arch = 'aarch64' if arch == 'aarch64' else 'x86_64' + if os_name == 'linux' and arch not in ('arm-gnueabihf',): + arch += '-gnu' base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == "windows": + if os_name == 'windows': ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -101,7 +285,6 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename - # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -133,7 +316,6 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") - # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -143,7 +325,6 @@ def is_junction(path): except OSError: return False - def create_junction(source, target): """ Create a junction from source to target. @@ -155,9 +336,7 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print( - f"⚠️ Source and target are the same ({source_path}); skipping junction creation." - ) + print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") # Still ensure the target directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -180,20 +359,18 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, - capture_output=True, - text=True, + check=True, capture_output=True, text=True ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") + links = LAUNCHER_STATE.get("managed_links", {}) + links[str(target_path)] = str(source_path) + update_launcher_state(managed_links=links) return True except subprocess.CalledProcessError as e: - print( - f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" - ) + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") target_path.mkdir(parents=True, exist_ok=True) return False - def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" appdata = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) @@ -203,18 +380,78 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") +def remove_managed_link(target): + target_path = Path(target) + try: + if not target_path.exists() and not target_path.is_symlink(): + return True + if target_path.is_symlink() or is_junction(target_path): + os.rmdir(str(target_path)) if target_path.is_dir() else target_path.unlink(missing_ok=True) + elif target_path.is_dir(): + shutil.rmtree(target_path, ignore_errors=True) + else: + target_path.unlink(missing_ok=True) + return True + except Exception as exc: + print(f"⚠️ Failed to remove {target_path}: {exc}") + return False + +def run_debug_menu(): + while True: + print("\n" + "=" * 60) + print(" Debug Launcher – Choose a Option") + print("=" * 60) + print("1) Remove mods link") + print("2) Remove resourcepacks link") + print("3) Remove all managed links") + print("4) Clear trusted mirror cache/logs") + print("5) Print effective config") + print("6) Run trusted-dir probes only") + print("b) Back") + choice = input("Select debug option: ").strip().lower() + + if choice == "1": + remove_managed_link(BASE_DIR / "mods") + elif choice == "2": + remove_managed_link(BASE_DIR / "resourcepacks") + elif choice == "3": + if input("Confirm remove all managed links? (yes/no): ").strip().lower() == "yes": + links = list(LAUNCHER_STATE.get("managed_links", {}).keys()) + for link in links: + remove_managed_link(link) + update_launcher_state(managed_links={}) + print("✅ Managed links removed.") + elif choice == "4": + if ACTIVE_TRUSTED_DIR: + for name in ("logs", "portablemc_bin", "python"): + p = ACTIVE_TRUSTED_DIR / name + if p.exists(): + try: + shutil.rmtree(p, ignore_errors=True) + except Exception as exc: + print(f"⚠️ Could not remove {p}: {exc}") + print("✅ Trusted cache/logs cleanup attempted.") + else: + print("ℹ️ No trusted directory selected.") + elif choice == "5": + print(json.dumps(LAUNCHER_STATE, indent=2, sort_keys=True)) + elif choice == "6": + selected, candidates, probes = _resolve_trusted_dir(LAUNCHER_STATE) + print(f"Selected trusted dir: {selected}") + print(json.dumps({str(k): v for k, v in probes.items()}, indent=2)) + elif choice == "b" or "q": + return + else: + print("Invalid debug choice.") # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print( - "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." - ) + print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") return ssl._create_unverified_context() return None - def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -227,7 +464,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, "wb") as f: + with open(dest_path, 'wb') as f: f.write(response.read()) return True except Exception as e2: @@ -235,12 +472,9 @@ def download_file(url, dest_path): return False else: print("❌ Download failed and insecure SSL is disabled.") - print( - "⚠️ If you are in a restricted network, set ALLOW_INSECURE_SSL=true and try again." - ) + print("⚠️ If you are in a restricted network, set ALLOW_INSECURE_SSL=true and try again.") return False - # --- Helper functions --- def find_msbuild_candidates(): """Return a list of candidate MSBuild.exe paths sorted by priority (highest first).""" @@ -250,21 +484,10 @@ def find_msbuild_candidates(): vswhere = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" if os.path.isfile(vswhere): try: - result = subprocess.run( - [ - vswhere, - "-latest", - "-products", - "*", - "-find", - "MSBuild\\**\\Bin\\MSBuild.exe", - ], - capture_output=True, - text=True, - timeout=5, - ) + result = subprocess.run([vswhere, "-latest", "-products", "*", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"], + capture_output=True, text=True, timeout=5) if result.returncode == 0: - for line in result.stdout.strip().split("\n"): + for line in result.stdout.strip().split('\n'): if line and os.path.isfile(line): candidates.append((line, 100)) # highest priority except Exception: @@ -273,76 +496,33 @@ def find_msbuild_candidates(): # 2. Hard‑coded candidates with descending priorities hardcoded = [ # Visual Studio 2026 (v18.0) - ( - r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), + (r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 99), + # Visual Studio 2022 (v17.0) - ( - r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), + (r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 90), + # Visual Studio 2019 (v16.0) - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 80), + # Visual Studio 2017 (v15.0) - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", 70), + # Standalone Build Tools (v14.0, v12.0) (r"C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe", 60), (r"C:\Program Files (x86)\MSBuild\12.0\Bin\MSBuild.exe", 50), + # .NET Framework (64‑bit preferred, then 32‑bit) (r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe", 40), (r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe", 30), @@ -362,7 +542,6 @@ def find_msbuild_candidates(): sorted_candidates = sorted(unique.items(), key=lambda x: x[1], reverse=True) return [path for path, _ in sorted_candidates] - def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" BASE_DIR.mkdir(parents=True, exist_ok=True) @@ -382,7 +561,6 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True - def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -401,16 +579,11 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True - def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run( - [str(EMBEDDED_PYTHON), "--version"], - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -424,7 +597,6 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False - def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -441,10 +613,8 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) # nosec + env=env_check, capture_output=True, text=True + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -453,20 +623,14 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True - def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" - print("📦 Installing portablemc via pip...") - if not run_pip_command( - ["-m", "pip", "install", "portablemc"], - isolated=True, - python_exe=EMBEDDED_PYTHON, - ): + print("📦 Installing portablemc (uv first, pip fallback)...") + if not install_packages_with_fallback(["portablemc"], python_exe=EMBEDDED_PYTHON, isolated=True): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) - def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -478,7 +642,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, "wb") as out_file: + with open(pip_script, 'wb') as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -486,7 +650,6 @@ def download_get_pip(): return None return pip_script - def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -496,7 +659,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -504,6 +667,44 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True +def run_uv_install(packages, python_exe=None, user=False): + """Try package install using uv. Returns True on success.""" + if python_exe is None: + python_exe = EMBEDDED_PYTHON + cmd = ["uv", "pip", "install", "--python", str(python_exe)] + if user: + cmd.append("--user") + cmd.extend(packages) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) # nosec + except Exception as exc: + print(f"⚠️ uv unavailable or blocked: {exc}") + return False + if result.returncode != 0: + print(f"⚠️ uv install failed ({result.returncode}); falling back to pip.") + if result.stderr: + print(result.stderr.strip()) + return False + if result.stdout: + print(result.stdout.strip()) + return True + +def install_packages_with_fallback(packages, python_exe=None, isolated=True, user=False): + """Install packages with uv first, then pip fallback.""" + if python_exe is None: + python_exe = EMBEDDED_PYTHON + if run_uv_install(packages, python_exe=python_exe, user=user): + update_launcher_state(installer_backend="uv") + return True + + pip_args = ["-m", "pip", "install"] + if user: + pip_args.append("--user") + pip_args.extend(packages) + ok = run_pip_command(pip_args, isolated=isolated, python_exe=python_exe) + if ok: + update_launcher_state(installer_backend="pip") + return ok def install_pip(python_exe=None): """Install pip into the given Python environment.""" @@ -516,13 +717,10 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [ - str(python_exe), - str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org", - ] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [str(python_exe), str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org"] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -530,28 +728,20 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True - def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not run_pip_command( - ["-m", "pip", "install", "--upgrade", "pip"], - isolated=True, - python_exe=python_exe, - ): + if not install_packages_with_fallback(["--upgrade", "pip"], isolated=True, python_exe=python_exe): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not run_pip_command( - ["-m", "pip", "install", pkg], isolated=True, python_exe=python_exe - ): + if not install_packages_with_fallback([pkg], isolated=True, python_exe=python_exe): print(f"❌ Failed to install {pkg}.") return False return True - def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -559,11 +749,9 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, - text=True, - check=True, - env={"PYTHONNOUSERSITE": "1"}, - ) # nosec + capture_output=True, text=True, check=True, + env={"PYTHONNOUSERSITE": "1"} + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -571,7 +759,6 @@ def get_certifi_path(python_exe=None): pass return None - def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -605,7 +792,6 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True - def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -617,13 +803,8 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run( - [str(binary_path), "--help"], - env=env, - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(binary_path), "--help"], env=env, + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -638,7 +819,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -646,7 +827,6 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None - def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -657,21 +837,148 @@ def ensure_portablemc(python_exe=None): if method: return method print("⚠️ Binary download failed, falling back to pip.") - print("📦 Installing portablemc via pip...") - if run_pip_command( - ["-m", "pip", "install", "portablemc"], isolated=True, python_exe=python_exe - ): + print("📦 Installing portablemc (uv first, pip fallback)...") + if install_packages_with_fallback(["portablemc"], isolated=True, python_exe=python_exe): method = test_portablemc(python_exe) if method: return method return None +def collect_system_details(): + """Collect diagnostic details. Individual probes can fail safely.""" + details = { + "timestamp": datetime.datetime.now().isoformat(), + "platform": platform.platform(), + "system": platform.system(), + "release": platform.release(), + "version": platform.version(), + "machine": platform.machine(), + "processor": platform.processor(), + "python_version": sys.version, + "python_executable": sys.executable, + "cwd": os.getcwd(), + "root_dir": str(ROOT_DIR), + "base_dir": str(BASE_DIR), + "trusted_dir": str(ACTIVE_TRUSTED_DIR) if ACTIVE_TRUSTED_DIR else "", + "project_logs_dir": str(PROJECT_LOGS_DIR), + "config_path": str(CONFIG_PATH), + "env_subset": { + k: os.environ.get(k, "") + for k in [ + "USERNAME", "USERDOMAIN", "COMPUTERNAME", "PROCESSOR_ARCHITECTURE", + "LOCALAPPDATA", "APPDATA", "TEMP", "TMP", "COMSPEC", "PSModulePath" + ] + } + } + + probes = { + "whoami": ["whoami"], + "ver": ["cmd", "/c", "ver"], + "where_python": ["where", "python"], + "where_py": ["where", "py"], + "where_msbuild": ["where", "MSBuild.exe"], + "where_powershell": ["where", "powershell.exe"], + } + details["probes"] = {} + for name, cmd in probes.items(): + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=5) # nosec + details["probes"][name] = { + "returncode": res.returncode, + "stdout": (res.stdout or "").strip(), + "stderr": (res.stderr or "").strip(), + } + except Exception as exc: + details["probes"][name] = {"error": f"{type(exc).__name__}: {exc}"} + return details + +def write_failure_dump(kind, message, command=None, output=None, returncode=None, exc=None, extra=None): + """Write a resilient failure dump log and return its path (or None).""" + try: + logs_dir = PROJECT_LOGS_DIR + logs_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + dump_path = logs_dir / f"{kind}_failure_{stamp}.log" + lines = [] + lines.append("=" * 80) + lines.append(f"{kind.upper()} FAILURE DUMP") + lines.append("=" * 80) + lines.append(f"Message: {message}") + if command: + lines.append(f"Command: {' '.join(command)}") + if returncode is not None: + lines.append(f"Return code: {returncode}") + lines.append("") + + if extra: + lines.append("[Extra]") + for key, value in extra.items(): + lines.append(f"{key}: {value}") + lines.append("") + + lines.append("[System Details]") + details = collect_system_details() + for key, value in details.items(): + lines.append(f"{key}: {value}") + lines.append("") + + if output: + lines.append("[Process Output]") + lines.append(output) + lines.append("") + + lines.append("[Stack Trace]") + if exc is not None: + lines.append("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) + else: + lines.append("(no Python exception captured)") + + with open(dump_path, "w", encoding="utf-8", errors="replace") as f: + f.write("\n".join(lines)) + if ACTIVE_TRUSTED_DIR: + try: + trusted_logs = ACTIVE_TRUSTED_DIR / "logs" + trusted_logs.mkdir(parents=True, exist_ok=True) + shutil.copy2(dump_path, trusted_logs / dump_path.name) + except Exception: + pass + update_launcher_state(success=False, last_error=message) + return dump_path + except Exception as dump_exc: + print(f"⚠️ Failed to write diagnostic dump: {dump_exc}") + return None + +def run_command_live(cmd, env=None, cwd=None): + """Run a command, stream output live, and capture it for diagnostics.""" + output_lines = [] + process = subprocess.Popen( # nosec + cmd, + env=env, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + universal_newlines=True, + ) + try: + for line in process.stdout: + print(line, end="") + output_lines.append(line) + returncode = process.wait() + return returncode, "".join(output_lines) + except KeyboardInterrupt: + try: + process.terminate() + except Exception: + pass + raise # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -712,18 +1019,10 @@ def add_candidate(p): # 4. Common install locations for ver in PYTHON_VERSIONS: - num = ver.replace(".", "") - for base in ( - r"C:\Python{}", - r"C:\Program Files\Python{}", - r"C:\Program Files (x86)\Python{}", - ): + num = ver.replace('.', '') + for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): add_candidate(base.format(num) + "\\python.exe") - user_dir = ( - Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) - / "Programs" - / f"Python{num}" - ) + user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" add_candidate(user_dir / "python.exe") add_candidate(r"C:\Windows\Sysnative\python.exe") add_candidate(r"C:\Windows\System32\python.exe") @@ -732,12 +1031,10 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run( - [str(p), "--version"], capture_output=True, text=True, timeout=2 - ) # nosec + result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: - match = re.search(r"\d+(?:\.\d+)+", combined) + match = re.search(r'\d+(?:\.\d+)+', combined) if match: version_str = match.group() valid.append((version_str, p)) @@ -747,12 +1044,11 @@ def add_candidate(p): if not valid: return None - valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best - # --- Launcher functions --- def launch_launcher(method, python_exe=None, extra_env=None): launcher_script = PORTABLEMC_PY @@ -799,7 +1095,6 @@ def launch_launcher(method, python_exe=None, extra_env=None): print("⏹️ Interrupted by user.") return True - def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -837,12 +1132,10 @@ def run_web_launcher(): print("📦 Installing required packages with system Python...") for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") - cmd = [str(sys_python), "-m", "pip", "install", "--user", pkg] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec - if result.returncode != 0: - print(f"❌ Failed to install {pkg}: {result.stderr}") + if not install_packages_with_fallback([pkg], python_exe=sys_python, isolated=False, user=True): + print(f"❌ Failed to install {pkg}.") return False - print(f" ✅ {pkg} installed.") + print(f" ✅ {pkg} installed via {LAUNCHER_STATE.get('installer_backend', 'pip')}.") # Test portablemc with system Python method = test_portablemc(sys_python) @@ -855,11 +1148,10 @@ def run_web_launcher(): extra_env = { "PYTHONUSERBASE": str(launcher_python_dir), "PYTHONNOUSERSITE": "0", - "PATH": os.environ["PATH"], # keep the original PATH + "PATH": os.environ["PATH"] # keep the original PATH } return launch_launcher(method, sys_python, extra_env) - def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") prepare_user_data() @@ -882,46 +1174,64 @@ def run_msbuild_launcher(): f"/p:Username={DEFAULT_USERNAME}", f"/p:ServerIp={DEFAULT_SERVER_IP}", f"/p:JvmOpts={DEFAULT_JVM_OPTS}", + "/p:UsePowerShell=true", + "/p:UseCsc=false", + "/p:UseVbs=false" ] print(f"Executing: {' '.join(cmd)}") try: - # Run without capturing output; let MSBuild print directly - result = subprocess.run(cmd, env=env) # nosec - if result.returncode == 0: + result_code, output = run_command_live(cmd, env=env) + if result_code == 0: print("✅ MSBuild succeeded.") return True else: - print( - f"⚠️ MSBuild at {msbuild_path} exited with code {result.returncode}. Trying next candidate." + dump = write_failure_dump( + kind="msbuild", + message=f"MSBuild candidate failed: {msbuild_path}", + command=cmd, + output=output, + returncode=result_code, + extra={"candidate": msbuild_path} ) + if dump: + print(f"📝 Failure dump written: {dump}") + print(f"⚠️ MSBuild at {msbuild_path} exited with code {result_code}. Trying next candidate.") + except KeyboardInterrupt: + raise except Exception as e: - print( - f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate." + dump = write_failure_dump( + kind="msbuild", + message=f"Exception while running MSBuild candidate: {msbuild_path}", + command=cmd, + exc=e, + extra={"candidate": msbuild_path} ) + if dump: + print(f"📝 Failure dump written: {dump}") + print(f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate.") print("❌ All MSBuild candidates failed.") return False - def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") prepare_user_data() + failure_context = {"mode": "cli", "server": DEFAULT_SERVER_IP, "username": DEFAULT_USERNAME} # Try embedded Python first if setup_embedded_python(): # Install portablemc (and certifi for SSL) into embedded Python method = install_portablemc_via_embedded() if not method: + dump = write_failure_dump("cli", "Embedded Python portablemc install failed.", extra=failure_context) + if dump: + print(f"📝 Failure dump written: {dump}") return False # Install certifi to get CA bundle print("📦 Installing certifi for SSL support...") - if not run_pip_command( - ["-m", "pip", "install", "certifi"], - isolated=True, - python_exe=EMBEDDED_PYTHON, - ): + if not install_packages_with_fallback(["certifi"], isolated=True, python_exe=EMBEDDED_PYTHON): print("⚠️ Failed to install certifi; SSL errors may occur.") else: print("✅ certifi installed.") @@ -933,23 +1243,18 @@ def run_cli_launcher(): ensure_junctions() # Build CLI arguments (module syntax) + jvm_arg_tokens = [arg for arg in DEFAULT_JVM_OPTS.split() if arg.strip()] cmd = [ - str(EMBEDDED_PYTHON), - "-m", - "portablemc", - "--main-dir", - ".", - "--output", - "human-color", + str(EMBEDDED_PYTHON), "-m", "portablemc", + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] + for token in reversed(jvm_arg_tokens): + cmd.insert(9, f"--jvm-arg={token}") env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" @@ -962,19 +1267,36 @@ def run_cli_launcher(): print(f"🚀 Launching: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec + result_code, output = run_command_live(cmd, env=env, cwd=BASE_DIR) + if result_code != 0: + dump = write_failure_dump( + "cli", + "Embedded Python CLI launch failed.", + command=cmd, + output=output, + returncode=result_code, + extra=failure_context + ) + if dump: + print(f"📝 Failure dump written: {dump}") + return False return True - except subprocess.CalledProcessError as e: + except KeyboardInterrupt: + raise + except Exception as e: + dump = write_failure_dump("cli", "Exception during embedded CLI launch.", command=cmd, exc=e, extra=failure_context) + if dump: + print(f"📝 Failure dump written: {dump}") print(f"❌ CLI launcher exited with error: {e}") return False - except KeyboardInterrupt: - print("⏹️ Interrupted by user.") - return True # Fallback to system Python print("\n⚠️ Embedded Python not usable. Trying system Python...") sys_python = get_system_python() if not sys_python: + dump = write_failure_dump("cli", "System Python not found for CLI fallback.", extra=failure_context) + if dump: + print(f"📝 Failure dump written: {dump}") print("❌ No system Python found. Cannot proceed.") return False @@ -989,14 +1311,23 @@ def run_cli_launcher(): # Install portablemc and certifi with system Python print("📦 Installing portablemc with system Python...") - cmd = [str(sys_python), "-m", "pip", "install", "--user", "portablemc", "certifi"] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec - if result.returncode != 0: - print(f"❌ Failed to install portablemc: {result.stderr}") + if not install_packages_with_fallback(["portablemc", "certifi"], python_exe=sys_python, isolated=False, user=True): + dump = write_failure_dump( + "cli", + "package install failed in system Python fallback (uv and pip).", + command=[str(sys_python), "-m", "pip/uv", "install", "--user", "portablemc", "certifi"], + extra=failure_context + ) + if dump: + print(f"📝 Failure dump written: {dump}") + print("❌ Failed to install portablemc/certifi.") return False method = test_portablemc(sys_python) if not method: + dump = write_failure_dump("cli", "portablemc unavailable after system Python install.", extra=failure_context) + if dump: + print(f"📝 Failure dump written: {dump}") print("❌ portablemc not available after installation.") return False @@ -1004,23 +1335,18 @@ def run_cli_launcher(): ensure_junctions() + jvm_arg_tokens = [arg for arg in DEFAULT_JVM_OPTS.split() if arg.strip()] cmd = [ - str(sys_python), - "-m", - "portablemc", - "--main-dir", - ".", - "--output", - "human-color", + str(sys_python), "-m", "portablemc", + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, - "--jvm-args", - DEFAULT_JVM_OPTS, + "--server", DEFAULT_SERVER_IP, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] + for token in reversed(jvm_arg_tokens): + cmd.insert(9, f"--jvm-arg={token}") env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) if cert_path: @@ -1029,17 +1355,31 @@ def run_cli_launcher(): print(f"🚀 Launching: {' '.join(cmd)}") try: - subprocess.run(cmd, env=env, cwd=BASE_DIR, check=True) # nosec + result_code, output = run_command_live(cmd, env=env, cwd=BASE_DIR) + if result_code != 0: + dump = write_failure_dump( + "cli", + "System Python CLI launch failed.", + command=cmd, + output=output, + returncode=result_code, + extra=failure_context + ) + if dump: + print(f"📝 Failure dump written: {dump}") + return False return True - except subprocess.CalledProcessError as e: + except KeyboardInterrupt: + raise + except Exception as e: + dump = write_failure_dump("cli", "Exception during system Python CLI launch.", command=cmd, exc=e, extra=failure_context) + if dump: + print(f"📝 Failure dump written: {dump}") print(f"❌ CLI launcher exited with error: {e}") return False - except KeyboardInterrupt: - print("⏹️ Interrupted by user.") - return True - def main(): + initialize_runtime_configuration() # Display menu print("\n" + "=" * 60) print(" Minecraft Launcher – Choose a Method") @@ -1051,11 +1391,17 @@ def main(): choice = input("\nEnter choice (1/2/3/q): ").strip() if choice == "1": + update_launcher_state(last_run_mode="web") success = run_web_launcher() elif choice == "2": + update_launcher_state(last_run_mode="msbuild") success = run_msbuild_launcher() elif choice == "3": + update_launcher_state(last_run_mode="cli") success = run_cli_launcher() + elif choice == "4": + run_debug_menu() + success = True elif choice.lower() == "q": print("Exiting.") sys.exit(0) @@ -1063,8 +1409,12 @@ def main(): print("Invalid choice. Exiting.") sys.exit(1) + update_launcher_state(success=bool(success), last_error="" if success else "launcher returned failure") sys.exit(0 if success else 1) - if __name__ == "__main__": - main() + try: + main() + except KeyboardInterrupt: + print("\n[HALT] Keyboard interrupted.") + sys.exit(130) From e990a4e7e226db70909e4b2711a5064e3a1da621 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 00:05:08 +0000 Subject: [PATCH 58/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 468 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 349 insertions(+), 119 deletions(-) diff --git a/main.py b/main.py index 61cd54c..a1b7651 100644 --- a/main.py +++ b/main.py @@ -23,7 +23,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != 'windows': +if platform.system().lower() != "windows": print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -40,9 +40,13 @@ PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ("1", "true", "yes") +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( + "1", + "true", + "yes", +) # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -79,6 +83,7 @@ "allowlist_trusted_dirs": [], } + def _safe_json_dump(path, payload): try: path.parent.mkdir(parents=True, exist_ok=True) @@ -88,6 +93,7 @@ def _safe_json_dump(path, payload): except Exception: return False + def _load_json_file(path): try: if not path.exists(): @@ -98,6 +104,7 @@ def _load_json_file(path): except Exception: return {} + def _get_builtin_trusted_candidates(): user_profile = Path(os.environ.get("USERPROFILE", Path.home())) local_app = Path(os.environ.get("LOCALAPPDATA", user_profile / "AppData/Local")) @@ -115,6 +122,7 @@ def _get_builtin_trusted_candidates(): temp_dir / "PortableMC", ] + def _probe_directory_access(path): result = { "exists": False, @@ -148,7 +156,9 @@ def _probe_directory_access(path): result["error"] = f"read failed: {exc}" try: - proc = subprocess.run(["cmd", "/c", "cd"], cwd=path, capture_output=True, text=True, timeout=3) # nosec + proc = subprocess.run( + ["cmd", "/c", "cd"], cwd=path, capture_output=True, text=True, timeout=3 + ) # nosec result["execute_ok"] = proc.returncode == 0 except Exception as exc: result["error"] = f"execute probe failed: {exc}" @@ -160,6 +170,7 @@ def _probe_directory_access(path): return result + def _resolve_trusted_dir(config): allowlist = config.get("allowlist_trusted_dirs", []) candidates = _get_builtin_trusted_candidates() @@ -178,7 +189,9 @@ def _resolve_trusted_dir(config): try: probe = _probe_directory_access(candidate) probes[key] = probe - if selected is None and all(probe.get(k) for k in ("create_ok", "write_ok", "read_ok", "execute_ok")): + if selected is None and all( + probe.get(k) for k in ("create_ok", "write_ok", "read_ok", "execute_ok") + ): selected = candidate except Exception as exc: probes[key] = {"error": f"{type(exc).__name__}: {exc}"} @@ -187,6 +200,7 @@ def _resolve_trusted_dir(config): selected = BASE_DIR return selected, candidates, probes + def _sync_config_to_trusted(config_path, trusted_dir): trusted_cfg = trusted_dir / "launcher_config.json" sync_mode = "copy" @@ -210,6 +224,7 @@ def _sync_config_to_trusted(config_path, trusted_dir): sync_mode = "disabled" return trusted_cfg, sync_mode + def _set_runtime_base_dir(new_base_dir): global BASE_DIR, EMBEDDED_DIR, EMBEDDED_PYTHON, PORTABLEMC_BIN_DIR BASE_DIR = Path(new_base_dir) @@ -217,10 +232,12 @@ def _set_runtime_base_dir(new_base_dir): EMBEDDED_PYTHON = EMBEDDED_DIR / "python.exe" PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" + def update_launcher_state(**updates): LAUNCHER_STATE.update(updates) _safe_json_dump(CONFIG_PATH, LAUNCHER_STATE) + def initialize_runtime_configuration(): global LAUNCHER_STATE, ACTIVE_TRUSTED_DIR loaded = _load_json_file(CONFIG_PATH) @@ -244,40 +261,42 @@ def initialize_runtime_configuration(): LAUNCHER_STATE["config_sync_mode"] = sync_mode _safe_json_dump(CONFIG_PATH, LAUNCHER_STATE) + # --- OS and architecture detection (for portablemc binary) --- SYSTEM = platform.system().lower() MACHINE = platform.machine().lower() ARCH_MAP = { - 'x86_64': 'x86_64', - 'amd64': 'x86_64', - 'i686': 'i686', - 'i386': 'i686', - 'aarch64': 'aarch64', - 'arm64': 'aarch64', - 'armv7l': 'arm-gnueabihf', - 'arm': 'arm-gnueabihf', + "x86_64": "x86_64", + "amd64": "x86_64", + "i686": "i686", + "i386": "i686", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "arm-gnueabihf", + "arm": "arm-gnueabihf", } OS_MAP = { - 'windows': 'windows', - 'linux': 'linux', - 'darwin': 'macos', + "windows": "windows", + "linux": "linux", + "darwin": "macos", } + def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, 'x86_64') - if os_name == 'macos': - arch = 'aarch64' if arch == 'aarch64' else 'x86_64' - if os_name == 'linux' and arch not in ('arm-gnueabihf',): - arch += '-gnu' + arch = ARCH_MAP.get(MACHINE, "x86_64") + if os_name == "macos": + arch = "aarch64" if arch == "aarch64" else "x86_64" + if os_name == "linux" and arch not in ("arm-gnueabihf",): + arch += "-gnu" base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == 'windows': + if os_name == "windows": ext = "zip" filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" else: @@ -285,6 +304,7 @@ def get_portablemc_url(): filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" return base + filename + # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -316,6 +336,7 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -325,6 +346,7 @@ def is_junction(path): except OSError: return False + def create_junction(source, target): """ Create a junction from source to target. @@ -336,7 +358,9 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") + print( + f"⚠️ Source and target are the same ({source_path}); skipping junction creation." + ) # Still ensure the target directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -359,7 +383,9 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True + check=True, + capture_output=True, + text=True, ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") links = LAUNCHER_STATE.get("managed_links", {}) @@ -367,10 +393,13 @@ def create_junction(source, target): update_launcher_state(managed_links=links) return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" appdata = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) @@ -380,13 +409,16 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + def remove_managed_link(target): target_path = Path(target) try: if not target_path.exists() and not target_path.is_symlink(): return True if target_path.is_symlink() or is_junction(target_path): - os.rmdir(str(target_path)) if target_path.is_dir() else target_path.unlink(missing_ok=True) + os.rmdir(str(target_path)) if target_path.is_dir() else target_path.unlink( + missing_ok=True + ) elif target_path.is_dir(): shutil.rmtree(target_path, ignore_errors=True) else: @@ -396,6 +428,7 @@ def remove_managed_link(target): print(f"⚠️ Failed to remove {target_path}: {exc}") return False + def run_debug_menu(): while True: print("\n" + "=" * 60) @@ -415,7 +448,10 @@ def run_debug_menu(): elif choice == "2": remove_managed_link(BASE_DIR / "resourcepacks") elif choice == "3": - if input("Confirm remove all managed links? (yes/no): ").strip().lower() == "yes": + if ( + input("Confirm remove all managed links? (yes/no): ").strip().lower() + == "yes" + ): links = list(LAUNCHER_STATE.get("managed_links", {}).keys()) for link in links: remove_managed_link(link) @@ -444,14 +480,18 @@ def run_debug_menu(): else: print("Invalid debug choice.") + # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") + print( + "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." + ) return ssl._create_unverified_context() return None + def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -464,7 +504,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, 'wb') as f: + with open(dest_path, "wb") as f: f.write(response.read()) return True except Exception as e2: @@ -472,9 +512,12 @@ def download_file(url, dest_path): return False else: print("❌ Download failed and insecure SSL is disabled.") - print("⚠️ If you are in a restricted network, set ALLOW_INSECURE_SSL=true and try again.") + print( + "⚠️ If you are in a restricted network, set ALLOW_INSECURE_SSL=true and try again." + ) return False + # --- Helper functions --- def find_msbuild_candidates(): """Return a list of candidate MSBuild.exe paths sorted by priority (highest first).""" @@ -484,10 +527,21 @@ def find_msbuild_candidates(): vswhere = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" if os.path.isfile(vswhere): try: - result = subprocess.run([vswhere, "-latest", "-products", "*", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"], - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [ + vswhere, + "-latest", + "-products", + "*", + "-find", + "MSBuild\\**\\Bin\\MSBuild.exe", + ], + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: - for line in result.stdout.strip().split('\n'): + for line in result.stdout.strip().split("\n"): if line and os.path.isfile(line): candidates.append((line, 100)) # highest priority except Exception: @@ -496,33 +550,76 @@ def find_msbuild_candidates(): # 2. Hard‑coded candidates with descending priorities hardcoded = [ # Visual Studio 2026 (v18.0) - (r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 99), - + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), # Visual Studio 2022 (v17.0) - (r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 90), - + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), # Visual Studio 2019 (v16.0) - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 80), - + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), # Visual Studio 2017 (v15.0) - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", 70), - + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), # Standalone Build Tools (v14.0, v12.0) (r"C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe", 60), (r"C:\Program Files (x86)\MSBuild\12.0\Bin\MSBuild.exe", 50), - # .NET Framework (64‑bit preferred, then 32‑bit) (r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe", 40), (r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe", 30), @@ -542,6 +639,7 @@ def find_msbuild_candidates(): sorted_candidates = sorted(unique.items(), key=lambda x: x[1], reverse=True) return [path for path, _ in sorted_candidates] + def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" BASE_DIR.mkdir(parents=True, exist_ok=True) @@ -561,6 +659,7 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True + def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -579,11 +678,16 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True + def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(EMBEDDED_PYTHON), "--version"], + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -597,6 +701,7 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False + def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -613,8 +718,10 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True - ) # nosec + env=env_check, + capture_output=True, + text=True, + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -623,14 +730,18 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True + def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" print("📦 Installing portablemc (uv first, pip fallback)...") - if not install_packages_with_fallback(["portablemc"], python_exe=EMBEDDED_PYTHON, isolated=True): + if not install_packages_with_fallback( + ["portablemc"], python_exe=EMBEDDED_PYTHON, isolated=True + ): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) + def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -642,7 +753,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, 'wb') as out_file: + with open(pip_script, "wb") as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -650,6 +761,7 @@ def download_get_pip(): return None return pip_script + def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -659,7 +771,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -667,6 +779,7 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True + def run_uv_install(packages, python_exe=None, user=False): """Try package install using uv. Returns True on success.""" if python_exe is None: @@ -689,7 +802,10 @@ def run_uv_install(packages, python_exe=None, user=False): print(result.stdout.strip()) return True -def install_packages_with_fallback(packages, python_exe=None, isolated=True, user=False): + +def install_packages_with_fallback( + packages, python_exe=None, isolated=True, user=False +): """Install packages with uv first, then pip fallback.""" if python_exe is None: python_exe = EMBEDDED_PYTHON @@ -706,6 +822,7 @@ def install_packages_with_fallback(packages, python_exe=None, isolated=True, use update_launcher_state(installer_backend="pip") return ok + def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -717,10 +834,13 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(python_exe), str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org"] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [ + str(python_exe), + str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org", + ] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -728,20 +848,26 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True + def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not install_packages_with_fallback(["--upgrade", "pip"], isolated=True, python_exe=python_exe): + if not install_packages_with_fallback( + ["--upgrade", "pip"], isolated=True, python_exe=python_exe + ): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not install_packages_with_fallback([pkg], isolated=True, python_exe=python_exe): + if not install_packages_with_fallback( + [pkg], isolated=True, python_exe=python_exe + ): print(f"❌ Failed to install {pkg}.") return False return True + def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -749,9 +875,11 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} - ) # nosec + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -759,6 +887,7 @@ def get_certifi_path(python_exe=None): pass return None + def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -792,6 +921,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True + def test_portablemc(python_exe=None): """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" # Try binary first @@ -803,8 +933,13 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run([str(binary_path), "--help"], env=env, - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(binary_path), "--help"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -819,7 +954,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -827,6 +962,7 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None + def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -838,12 +974,15 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc (uv first, pip fallback)...") - if install_packages_with_fallback(["portablemc"], isolated=True, python_exe=python_exe): + if install_packages_with_fallback( + ["portablemc"], isolated=True, python_exe=python_exe + ): method = test_portablemc(python_exe) if method: return method return None + def collect_system_details(): """Collect diagnostic details. Individual probes can fail safely.""" details = { @@ -865,10 +1004,18 @@ def collect_system_details(): "env_subset": { k: os.environ.get(k, "") for k in [ - "USERNAME", "USERDOMAIN", "COMPUTERNAME", "PROCESSOR_ARCHITECTURE", - "LOCALAPPDATA", "APPDATA", "TEMP", "TMP", "COMSPEC", "PSModulePath" + "USERNAME", + "USERDOMAIN", + "COMPUTERNAME", + "PROCESSOR_ARCHITECTURE", + "LOCALAPPDATA", + "APPDATA", + "TEMP", + "TMP", + "COMSPEC", + "PSModulePath", ] - } + }, } probes = { @@ -892,7 +1039,10 @@ def collect_system_details(): details["probes"][name] = {"error": f"{type(exc).__name__}: {exc}"} return details -def write_failure_dump(kind, message, command=None, output=None, returncode=None, exc=None, extra=None): + +def write_failure_dump( + kind, message, command=None, output=None, returncode=None, exc=None, extra=None +): """Write a resilient failure dump log and return its path (or None).""" try: logs_dir = PROJECT_LOGS_DIR @@ -929,7 +1079,9 @@ def write_failure_dump(kind, message, command=None, output=None, returncode=None lines.append("[Stack Trace]") if exc is not None: - lines.append("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) + lines.append( + "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + ) else: lines.append("(no Python exception captured)") @@ -948,6 +1100,7 @@ def write_failure_dump(kind, message, command=None, output=None, returncode=None print(f"⚠️ Failed to write diagnostic dump: {dump_exc}") return None + def run_command_live(cmd, env=None, cwd=None): """Run a command, stream output live, and capture it for diagnostics.""" output_lines = [] @@ -974,11 +1127,12 @@ def run_command_live(cmd, env=None, cwd=None): pass raise + # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -1019,10 +1173,18 @@ def add_candidate(p): # 4. Common install locations for ver in PYTHON_VERSIONS: - num = ver.replace('.', '') - for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): + num = ver.replace(".", "") + for base in ( + r"C:\Python{}", + r"C:\Program Files\Python{}", + r"C:\Program Files (x86)\Python{}", + ): add_candidate(base.format(num) + "\\python.exe") - user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" + user_dir = ( + Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + / "Programs" + / f"Python{num}" + ) add_candidate(user_dir / "python.exe") add_candidate(r"C:\Windows\Sysnative\python.exe") add_candidate(r"C:\Windows\System32\python.exe") @@ -1031,10 +1193,12 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec + result = subprocess.run( + [str(p), "--version"], capture_output=True, text=True, timeout=2 + ) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: - match = re.search(r'\d+(?:\.\d+)+', combined) + match = re.search(r"\d+(?:\.\d+)+", combined) if match: version_str = match.group() valid.append((version_str, p)) @@ -1044,11 +1208,12 @@ def add_candidate(p): if not valid: return None - valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best + # --- Launcher functions --- def launch_launcher(method, python_exe=None, extra_env=None): launcher_script = PORTABLEMC_PY @@ -1095,6 +1260,7 @@ def launch_launcher(method, python_exe=None, extra_env=None): print("⏹️ Interrupted by user.") return True + def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -1132,10 +1298,14 @@ def run_web_launcher(): print("📦 Installing required packages with system Python...") for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") - if not install_packages_with_fallback([pkg], python_exe=sys_python, isolated=False, user=True): + if not install_packages_with_fallback( + [pkg], python_exe=sys_python, isolated=False, user=True + ): print(f"❌ Failed to install {pkg}.") return False - print(f" ✅ {pkg} installed via {LAUNCHER_STATE.get('installer_backend', 'pip')}.") + print( + f" ✅ {pkg} installed via {LAUNCHER_STATE.get('installer_backend', 'pip')}." + ) # Test portablemc with system Python method = test_portablemc(sys_python) @@ -1148,10 +1318,11 @@ def run_web_launcher(): extra_env = { "PYTHONUSERBASE": str(launcher_python_dir), "PYTHONNOUSERSITE": "0", - "PATH": os.environ["PATH"] # keep the original PATH + "PATH": os.environ["PATH"], # keep the original PATH } return launch_launcher(method, sys_python, extra_env) + def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") prepare_user_data() @@ -1176,7 +1347,7 @@ def run_msbuild_launcher(): f"/p:JvmOpts={DEFAULT_JVM_OPTS}", "/p:UsePowerShell=true", "/p:UseCsc=false", - "/p:UseVbs=false" + "/p:UseVbs=false", ] print(f"Executing: {' '.join(cmd)}") try: @@ -1191,11 +1362,13 @@ def run_msbuild_launcher(): command=cmd, output=output, returncode=result_code, - extra={"candidate": msbuild_path} + extra={"candidate": msbuild_path}, ) if dump: print(f"📝 Failure dump written: {dump}") - print(f"⚠️ MSBuild at {msbuild_path} exited with code {result_code}. Trying next candidate.") + print( + f"⚠️ MSBuild at {msbuild_path} exited with code {result_code}. Trying next candidate." + ) except KeyboardInterrupt: raise except Exception as e: @@ -1204,34 +1377,47 @@ def run_msbuild_launcher(): message=f"Exception while running MSBuild candidate: {msbuild_path}", command=cmd, exc=e, - extra={"candidate": msbuild_path} + extra={"candidate": msbuild_path}, ) if dump: print(f"📝 Failure dump written: {dump}") - print(f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate.") + print( + f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate." + ) print("❌ All MSBuild candidates failed.") return False + def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") prepare_user_data() - failure_context = {"mode": "cli", "server": DEFAULT_SERVER_IP, "username": DEFAULT_USERNAME} + failure_context = { + "mode": "cli", + "server": DEFAULT_SERVER_IP, + "username": DEFAULT_USERNAME, + } # Try embedded Python first if setup_embedded_python(): # Install portablemc (and certifi for SSL) into embedded Python method = install_portablemc_via_embedded() if not method: - dump = write_failure_dump("cli", "Embedded Python portablemc install failed.", extra=failure_context) + dump = write_failure_dump( + "cli", + "Embedded Python portablemc install failed.", + extra=failure_context, + ) if dump: print(f"📝 Failure dump written: {dump}") return False # Install certifi to get CA bundle print("📦 Installing certifi for SSL support...") - if not install_packages_with_fallback(["certifi"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not install_packages_with_fallback( + ["certifi"], isolated=True, python_exe=EMBEDDED_PYTHON + ): print("⚠️ Failed to install certifi; SSL errors may occur.") else: print("✅ certifi installed.") @@ -1245,13 +1431,19 @@ def run_cli_launcher(): # Build CLI arguments (module syntax) jvm_arg_tokens = [arg for arg in DEFAULT_JVM_OPTS.split() if arg.strip()] cmd = [ - str(EMBEDDED_PYTHON), "-m", "portablemc", - "--main-dir", ".", - "--output", "human-color", + str(EMBEDDED_PYTHON), + "-m", + "portablemc", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, + "--server", + DEFAULT_SERVER_IP, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] for token in reversed(jvm_arg_tokens): cmd.insert(9, f"--jvm-arg={token}") @@ -1275,7 +1467,7 @@ def run_cli_launcher(): command=cmd, output=output, returncode=result_code, - extra=failure_context + extra=failure_context, ) if dump: print(f"📝 Failure dump written: {dump}") @@ -1284,7 +1476,13 @@ def run_cli_launcher(): except KeyboardInterrupt: raise except Exception as e: - dump = write_failure_dump("cli", "Exception during embedded CLI launch.", command=cmd, exc=e, extra=failure_context) + dump = write_failure_dump( + "cli", + "Exception during embedded CLI launch.", + command=cmd, + exc=e, + extra=failure_context, + ) if dump: print(f"📝 Failure dump written: {dump}") print(f"❌ CLI launcher exited with error: {e}") @@ -1294,7 +1492,9 @@ def run_cli_launcher(): print("\n⚠️ Embedded Python not usable. Trying system Python...") sys_python = get_system_python() if not sys_python: - dump = write_failure_dump("cli", "System Python not found for CLI fallback.", extra=failure_context) + dump = write_failure_dump( + "cli", "System Python not found for CLI fallback.", extra=failure_context + ) if dump: print(f"📝 Failure dump written: {dump}") print("❌ No system Python found. Cannot proceed.") @@ -1311,12 +1511,22 @@ def run_cli_launcher(): # Install portablemc and certifi with system Python print("📦 Installing portablemc with system Python...") - if not install_packages_with_fallback(["portablemc", "certifi"], python_exe=sys_python, isolated=False, user=True): + if not install_packages_with_fallback( + ["portablemc", "certifi"], python_exe=sys_python, isolated=False, user=True + ): dump = write_failure_dump( "cli", "package install failed in system Python fallback (uv and pip).", - command=[str(sys_python), "-m", "pip/uv", "install", "--user", "portablemc", "certifi"], - extra=failure_context + command=[ + str(sys_python), + "-m", + "pip/uv", + "install", + "--user", + "portablemc", + "certifi", + ], + extra=failure_context, ) if dump: print(f"📝 Failure dump written: {dump}") @@ -1325,7 +1535,11 @@ def run_cli_launcher(): method = test_portablemc(sys_python) if not method: - dump = write_failure_dump("cli", "portablemc unavailable after system Python install.", extra=failure_context) + dump = write_failure_dump( + "cli", + "portablemc unavailable after system Python install.", + extra=failure_context, + ) if dump: print(f"📝 Failure dump written: {dump}") print("❌ portablemc not available after installation.") @@ -1337,13 +1551,19 @@ def run_cli_launcher(): jvm_arg_tokens = [arg for arg in DEFAULT_JVM_OPTS.split() if arg.strip()] cmd = [ - str(sys_python), "-m", "portablemc", - "--main-dir", ".", - "--output", "human-color", + str(sys_python), + "-m", + "portablemc", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, + "--server", + DEFAULT_SERVER_IP, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] for token in reversed(jvm_arg_tokens): cmd.insert(9, f"--jvm-arg={token}") @@ -1363,7 +1583,7 @@ def run_cli_launcher(): command=cmd, output=output, returncode=result_code, - extra=failure_context + extra=failure_context, ) if dump: print(f"📝 Failure dump written: {dump}") @@ -1372,12 +1592,19 @@ def run_cli_launcher(): except KeyboardInterrupt: raise except Exception as e: - dump = write_failure_dump("cli", "Exception during system Python CLI launch.", command=cmd, exc=e, extra=failure_context) + dump = write_failure_dump( + "cli", + "Exception during system Python CLI launch.", + command=cmd, + exc=e, + extra=failure_context, + ) if dump: print(f"📝 Failure dump written: {dump}") print(f"❌ CLI launcher exited with error: {e}") return False + def main(): initialize_runtime_configuration() # Display menu @@ -1409,9 +1636,12 @@ def main(): print("Invalid choice. Exiting.") sys.exit(1) - update_launcher_state(success=bool(success), last_error="" if success else "launcher returned failure") + update_launcher_state( + success=bool(success), last_error="" if success else "launcher returned failure" + ) sys.exit(0 if success else 1) + if __name__ == "__main__": try: main() From 796a175b54813b0efca2cd503bff933e69f4534a Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:08:45 +0100 Subject: [PATCH 59/70] Enhance Launcher.targets with conditional execution Updated the Launcher.targets file to include conditional execution for PowerShell and VBScript fallbacks, and added properties to control their usage. --- scripts/Launcher.targets | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/scripts/Launcher.targets b/scripts/Launcher.targets index 5a74cd6..3bdde96 100644 --- a/scripts/Launcher.targets +++ b/scripts/Launcher.targets @@ -1,6 +1,11 @@ + + true + false + false + $(TEMP) $(TempDir)\PortableMCLoader.cs $(TempDir)\PortableMCLoader.exe @@ -31,50 +36,50 @@ - - - - + true - - + - + - + true - - + - + - + true - \ No newline at end of file + From fefb2dc79f282c27d342cdbd3ef39cff121a0e71 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:09:13 +0100 Subject: [PATCH 60/70] Refactor Launcher.ps1 for improved error handling --- scripts/Launcher.ps1 | 83 +++++++++++++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/scripts/Launcher.ps1 b/scripts/Launcher.ps1 index 9705d9a..dc56d72 100644 --- a/scripts/Launcher.ps1 +++ b/scripts/Launcher.ps1 @@ -4,6 +4,8 @@ param( [string]$JvmOpts ) +$ErrorActionPreference = "Stop" + # Set compatibility layer to avoid UAC prompts $env:__COMPAT_LAYER = "RUNASINVOKER" @@ -74,36 +76,61 @@ if (-not (Test-Path $exePath)) { } # --- Process JVM options and launch --- -$jvmArgs = $JvmOpts -replace ' ', ',' -$arguments = "--main-dir . start --join-server $ServerIp --jvm-arg=$jvmArgs fabric: -u $Username" +$portablemcArgs = @("--main-dir", ".", "start", "--join-server", $ServerIp) +$jvmArgList = @() +if ($JvmOpts) { + $jvmArgList = ($JvmOpts -split '\s+') | Where-Object { $_ -and $_.Trim() -ne "" } +} +foreach ($jvmArg in $jvmArgList) { + # Use --jvm-arg= so values like -Xmx3G are not parsed as options. + $portablemcArgs += "--jvm-arg=$jvmArg" +} +$portablemcArgs += @("fabric:", "-u", $Username) -Write-Host "Launching: $exePath $arguments" Write-Host "Working directory: $baseDir" - -# Ensure the environment variable is set for the child process $env:__COMPAT_LAYER = "RUNASINVOKER" +Push-Location $baseDir +try { + # Try native portablemc.exe first; if blocked or returns non-zero, fall back to Python module. + if (Test-Path $exePath) { + try { + Write-Host "Launching native binary: $exePath $($portablemcArgs -join ' ')" + & $exePath @portablemcArgs + $nativeExit = $LASTEXITCODE + Write-Host "Native exit code: $nativeExit" + if ($nativeExit -eq 0) { + exit 0 + } + Write-Host "Native launcher returned non-zero exit code ($nativeExit). Trying Python fallback..." + } catch { + Write-Host "Native portablemc.exe launch failed (likely policy block): $($_.Exception.Message)" + Write-Host "Trying Python module fallback..." + } + } -# Launch Minecraft with output redirection -$tempOut = Join-Path $env:TEMP "portablemc_out.txt" -$tempErr = Join-Path $env:TEMP "portablemc_err.txt" -$process = Start-Process -FilePath $exePath -ArgumentList $arguments -WorkingDirectory $baseDir -NoNewWindow -PassThru -RedirectStandardOutput $tempOut -RedirectStandardError $tempErr - -$timeout = 30 -$elapsed = 0 -while ($elapsed -lt $timeout) { - if ($process.HasExited) { break } - Start-Sleep -Seconds 1 - $elapsed++ -} + $pythonLaunchers = @( + @{ File = "py"; Args = @("-3", "-m", "portablemc") }, + @{ File = "python"; Args = @("-m", "portablemc") }, + @{ File = "python3"; Args = @("-m", "portablemc") } + ) + + foreach ($launcher in $pythonLaunchers) { + try { + $allArgs = @($launcher.Args + $portablemcArgs) + Write-Host "Launching Python fallback: $($launcher.File) $($allArgs -join ' ')" + & $launcher.File @allArgs + $pyExit = $LASTEXITCODE + Write-Host "Python launcher '$($launcher.File)' exit code: $pyExit" + if ($pyExit -eq 0) { + exit 0 + } + } catch { + Write-Host "Python launcher '$($launcher.File)' failed: $($_.Exception.Message)" + } + } -if ($process.HasExited) { - Write-Host "Process exited with code $($process.ExitCode)" - if (Test-Path $tempOut) { Get-Content $tempOut } - if (Test-Path $tempErr) { Get-Content $tempErr } - Remove-Item $tempOut, $tempErr -Force -ErrorAction SilentlyContinue - exit $process.ExitCode -} else { - Write-Host "Process still running after $timeout seconds - detaching." - Remove-Item $tempOut, $tempErr -Force -ErrorAction SilentlyContinue - exit 0 -} \ No newline at end of file + Write-Host "ERROR: All launch methods failed (native and Python fallback)." + exit 1 +} finally { + Pop-Location +} From 31e7eb500d2a887eb117de76ad30b11f2be9e927 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:09:32 +0100 Subject: [PATCH 61/70] Refactor Launcher.vbs for improved functionality --- scripts/Launcher.vbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/Launcher.vbs b/scripts/Launcher.vbs index 7bf6d3d..bbfc53f 100644 --- a/scripts/Launcher.vbs +++ b/scripts/Launcher.vbs @@ -162,4 +162,4 @@ Else Wend WScript.Echo "Process exited with code " & proc.ExitCode WScript.Quit proc.ExitCode -End If \ No newline at end of file +End If From 092316d7093526563acf28e786359e810ef083dd Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:09:57 +0100 Subject: [PATCH 62/70] Refactor PortableMCLoader.cs for improved clarity --- scripts/PortableMCLoader.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/PortableMCLoader.cs b/scripts/PortableMCLoader.cs index 68ec6df..27d6f20 100644 --- a/scripts/PortableMCLoader.cs +++ b/scripts/PortableMCLoader.cs @@ -154,4 +154,4 @@ static void FlattenDirectory(string dir) Directory.Delete(subDir, true); } } -} \ No newline at end of file +} From 068f60824453c698c25c96dc325fcce9dd86b681 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:11:49 +0100 Subject: [PATCH 63/70] Update portablemc.py with cursor See pull request for more details --- scripts/portablemc.py | 127 +++++++++++------------------------------- 1 file changed, 33 insertions(+), 94 deletions(-) diff --git a/scripts/portablemc.py b/scripts/portablemc.py index b4956e4..82481a9 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -32,14 +32,12 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") - def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace("&", "&").replace("<", "<").replace(">", ">") - + return s.replace('&', '&').replace('<', '<').replace('>', '>') # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") +VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -58,7 +56,6 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil - PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -71,7 +68,6 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter - ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -927,38 +923,34 @@ def escape_html(s): """ - # --- ROUTES --- -@socketio.on("connect") +@socketio.on('connect') def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f"Client connected: {client_id} (Total: {connected_clients})") - emit("status", {"core": "online", "minecraft": "checking"}) - + print(f'Client connected: {client_id} (Total: {connected_clients})') + emit('status', {'core': 'online', 'minecraft': 'checking'}) -@socketio.on("disconnect") +@socketio.on('disconnect') def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f"Client disconnected: {client_id} (Total: {connected_clients})") + print(f'Client disconnected: {client_id} (Total: {connected_clients})') - -@socketio.on("ping_minecraft") +@socketio.on('ping_minecraft') def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit("minecraft_status", {"online": True}) + emit('minecraft_status', {'online': True}) except Exception: - emit("minecraft_status", {"online": False}) - + emit('minecraft_status', {'online': False}) @app.route("/ping") def ping(): @@ -971,7 +963,6 @@ def ping(): except Exception: return jsonify(online=False), 200 - @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -981,17 +972,15 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): - def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -1000,44 +989,31 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: - def error_gen(): - payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): - def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: - def error_gen(): - payload = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1046,15 +1022,13 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m", + "\x1b[90mPlease close the game before launching again.\x1b[0m" ] - def error_gen(): for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1069,9 +1043,7 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get( - "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") - ) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1127,15 +1099,13 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} - ) + busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == "nt": + if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1151,7 +1121,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo, + startupinfo=startupinfo ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1163,12 +1133,7 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", - } - ) + disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1181,7 +1146,7 @@ def generate(): break continue - raw_line = line.rstrip("\n") + raw_line = line.rstrip('\n') now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1192,23 +1157,18 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert( - safe_line, full=False - ).strip() - payload = json.dumps({"type": "html", "content": html_line}) + html_line = ansi_converter.convert(safe_line, full=False).strip() + payload = json.dumps({'type': 'html', 'content': html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) else: - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if ( - current_file != last_progress - and (now - last_send_time) > update_interval - ): + if current_file != last_progress and (now - last_send_time) > update_interval: yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1222,21 +1182,14 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps( - { - "type": "ansi", - "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps( - {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} - ) + payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) yield f"data: {payload}\n\n" except Exception: pass @@ -1251,18 +1204,8 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", - } - ) - tip_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", - } - ) + ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) + tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1275,12 +1218,10 @@ def generate(): response.call_on_close(closed_event.set) return response - @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) - def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1307,7 +1248,6 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") - def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1315,7 +1255,6 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) - signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": From 61e24246e850a625d47c6b9a434ace3494799157 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 00:12:00 +0000 Subject: [PATCH 64/70] Apply automatic ruff fixes and formatting [skip ci] --- scripts/portablemc.py | 127 +++++++++++++++++++++++++++++++----------- 1 file changed, 94 insertions(+), 33 deletions(-) diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 82481a9..b4956e4 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -32,12 +32,14 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") + def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') + return s.replace("&", "&").replace("<", "<").replace(">", ">") + # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -56,6 +58,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -68,6 +71,7 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -923,34 +927,38 @@ def escape_html(s): """ + # --- ROUTES --- -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) + print(f"Client connected: {client_id} (Total: {connected_clients})") + emit("status", {"core": "online", "minecraft": "checking"}) + -@socketio.on('disconnect') +@socketio.on("disconnect") def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') + print(f"Client disconnected: {client_id} (Total: {connected_clients})") -@socketio.on('ping_minecraft') + +@socketio.on("ping_minecraft") def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit('minecraft_status', {'online': True}) + emit("minecraft_status", {"online": True}) except Exception: - emit('minecraft_status', {'online': False}) + emit("minecraft_status", {"online": False}) + @app.route("/ping") def ping(): @@ -963,6 +971,7 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -972,15 +981,17 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): + def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -989,31 +1000,44 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): + def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", + } + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1022,13 +1046,15 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" + "\x1b[90mPlease close the game before launching again.\x1b[0m", ] + def error_gen(): for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1043,7 +1069,9 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + local_appdata = os.environ.get( + "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") + ) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1099,13 +1127,15 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + busy_payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} + ) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == 'nt': + if os.name == "nt": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1121,7 +1151,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo + startupinfo=startupinfo, ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1133,7 +1163,12 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + disc_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", + } + ) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1146,7 +1181,7 @@ def generate(): break continue - raw_line = line.rstrip('\n') + raw_line = line.rstrip("\n") now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1157,18 +1192,23 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) + html_line = ansi_converter.convert( + safe_line, full=False + ).strip() + payload = json.dumps({"type": "html", "content": html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: + if ( + current_file != last_progress + and (now - last_send_time) > update_interval + ): yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1182,14 +1222,21 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", + } + ) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + payload = json.dumps( + {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} + ) yield f"data: {payload}\n\n" except Exception: pass @@ -1204,8 +1251,18 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + ended_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", + } + ) + tip_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", + } + ) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1218,10 +1275,12 @@ def generate(): response.call_on_close(closed_event.set) return response + @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1248,6 +1307,7 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1255,6 +1315,7 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": From 1530c5ec9982cd426d4a59d3337dd8349bcde516 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:59:18 +0100 Subject: [PATCH 65/70] Enhance README with Debug Menu and structure updates Added a Debug Menu option and updated folder structure details. --- README.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cd3bc9d..7f126d8 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ A Python‑based Minecraft launcher that runs in a browser using [Flask](https:/ ![Preview](static/preview.jpg) +> [!WARNING] +> This project is for education, launcher portability, and system compatibility testing. +> Do not use it for bypassing security policy without authorization. + ## Features - **Web interface** with live console (via Flask‑SocketIO) @@ -12,18 +16,21 @@ A Python‑based Minecraft launcher that runs in a browser using [Flask](https:/ - **PowerShell & VBScript fallbacks** for the most restricted environments - **Junctions** for `mods` and `resourcepacks` (or regular directories if junctions are blocked) - **All data stored in `%LOCALAPPDATA%\PortableMC`** – no clutter in the project folder +- **Debugging Menu** - hidden option with many debugging options + +## Folder Structure & Trusted Dir Strategy +The launcher uses a hybrid strategy to find a writable/executable directory, prioritizing `%LOCALAPPDATA%\PortableMC`, then falling back to `%TEMP%` or `%PUBLIC%` if necessary. -## Folder Structure ```text Minecraft-Portable-Web/ ├── main.py # Entry point – menu & bootstrapping -├── portablemc.py # Web server (Flask + SocketIO) ├── scripts/ # Launcher helper scripts │ ├── Launcher.targets # MSBuild task │ ├── PortableMCLoader.cs # C# loader (compiled if needed) │ ├── Launcher.vbs # VBScript fallback │ └── Launcher.ps1 # PowerShell fallback -├── static/ # Static assets (logo, ansi_up, socket.io) +| └── portablemc.py # Web server (Flask + SocketIO) +├── static/ # Web UI Assets ├── mods/ (optional) your mods ├── resourcepacks/ (optional) your resource packs ├── options.txt (optional) Minecraft settings @@ -39,8 +46,8 @@ Minecraft-Portable-Web/ - (Optional) VBScript support – used as a final fallback. ## TL;DR -* Test in **Windows Sandbox** * Test on a **Restricted Environment** by **Group Policy** +* Port all code to **Rust** ## Getting Started @@ -54,6 +61,7 @@ Then choose a method: * `1` – Web launcher (opens `portablemc.py` in your browser) * `2` – MSBuild launcher (uses `Launcher.targets`, compiles C# loader in memory) * `3` – CLI launcher (runs `portablemc` directly in the terminal) +* `4` - Debug Menu (shows a hidden debug list) ## How It Works (Bypass Chain) The launcher tries the most reliable method first, falling back if blocked: @@ -63,7 +71,8 @@ The launcher tries the most reliable method first, falling back if blocked: 3. **PowerShell** (with `-ExecutionPolicy Bypass`). 4. **VBScript** (via `cscript`). -If all fail, the script reports an error. +If a method is blocked, it tries the next. All launchers set __COMPAT_LAYER=RUNASINVOKER to avoid UAC prompts. +If all fails, the script will leave a log dump which you can use to [open an issue](https://github.com/PythonChicken123/Minecraft-Portable-Web/issues) All downloaded files (embedded Python, portablemc binary) are stored in `%LOCALAPPDATA%\PortableMC`. Junctions are created for `mods` and `resourcepacks` so that files placed in the project folder appear inside the game. @@ -89,3 +98,4 @@ MIT * [ansi2html](https://github.com/ralphbean/ansi2html) & [ansi_up](https://github.com/drudru/ansi_up) – ANSI colour conversion * [Socket.io](https://socket.io) - Communication between the web and Flask interfaces * [Ruff Linter](https://github.com/astral-sh/ruff) - An extremely fast Python linter +* [uv](https://github.com/astral-sh/uv) - An extremely fast Python package and project manager, written in Rust. From 39d799cf947c73e86e9f290b98573ae357408fa2 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:05:26 +0100 Subject: [PATCH 66/70] Update main.py --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index a1b7651..b611dbc 100644 --- a/main.py +++ b/main.py @@ -475,7 +475,7 @@ def run_debug_menu(): selected, candidates, probes = _resolve_trusted_dir(LAUNCHER_STATE) print(f"Selected trusted dir: {selected}") print(json.dumps({str(k): v for k, v in probes.items()}, indent=2)) - elif choice == "b" or "q": + elif choice in ("b", "q"): return else: print("Invalid debug choice.") From cdbfff37ca78e34eff9721a2011d911ad6e84e26 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:36:20 +0100 Subject: [PATCH 67/70] Update README.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7f126d8..b151ef4 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ The launcher tries the most reliable method first, falling back if blocked: 4. **VBScript** (via `cscript`). If a method is blocked, it tries the next. All launchers set __COMPAT_LAYER=RUNASINVOKER to avoid UAC prompts. -If all fails, the script will leave a log dump which you can use to [open an issue](https://github.com/PythonChicken123/Minecraft-Portable-Web/issues) +If all methods fail, the script will leave a log dump which you can use to [open an issue](https://github.com/PythonChicken123/Minecraft-Portable-Web/issues) All downloaded files (embedded Python, portablemc binary) are stored in `%LOCALAPPDATA%\PortableMC`. Junctions are created for `mods` and `resourcepacks` so that files placed in the project folder appear inside the game. From 54bd09093110d1f844efb2c0d1367d3b87452efd Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:23:22 +0100 Subject: [PATCH 68/70] Update --- main.py | 629 +++++++++++++++--------------------- scripts/Launcher.ps1 | 17 +- scripts/Launcher.targets | 2 +- scripts/Launcher.vbs | 11 +- scripts/PortableMCLoader.cs | 10 +- scripts/portablemc.py | 129 ++------ 6 files changed, 323 insertions(+), 475 deletions(-) diff --git a/main.py b/main.py index b611dbc..d5fb97e 100644 --- a/main.py +++ b/main.py @@ -23,7 +23,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != "windows": +if platform.system().lower() != 'windows': print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -36,17 +36,16 @@ BASE_DIR = APPDATA / "PortableMC" EMBEDDED_DIR = BASE_DIR / "python" EMBEDDED_PYTHON = EMBEDDED_DIR / "python.exe" +PORTABLEMC_VERSION = "5.0.2" +PORTABLEMC_RELEASE_BASE = f"https://github.com/mindstorm38/portablemc/releases/download/v{PORTABLEMC_VERSION}" PYTHON_VERSION = "3.14.3" PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").lower() in ( - "1", - "true", - "yes", -) +TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"} +ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").strip().lower() in TRUTHY_ENV_VALUES # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -81,9 +80,9 @@ "installer_backend": "", "managed_links": {}, "allowlist_trusted_dirs": [], + "include_sensitive_env_details": False, } - def _safe_json_dump(path, payload): try: path.parent.mkdir(parents=True, exist_ok=True) @@ -93,7 +92,6 @@ def _safe_json_dump(path, payload): except Exception: return False - def _load_json_file(path): try: if not path.exists(): @@ -104,7 +102,6 @@ def _load_json_file(path): except Exception: return {} - def _get_builtin_trusted_candidates(): user_profile = Path(os.environ.get("USERPROFILE", Path.home())) local_app = Path(os.environ.get("LOCALAPPDATA", user_profile / "AppData/Local")) @@ -122,7 +119,6 @@ def _get_builtin_trusted_candidates(): temp_dir / "PortableMC", ] - def _probe_directory_access(path): result = { "exists": False, @@ -156,9 +152,7 @@ def _probe_directory_access(path): result["error"] = f"read failed: {exc}" try: - proc = subprocess.run( - ["cmd", "/c", "cd"], cwd=path, capture_output=True, text=True, timeout=3 - ) # nosec + proc = subprocess.run(["cmd", "/c", "cd"], cwd=path, capture_output=True, text=True, timeout=3) # nosec result["execute_ok"] = proc.returncode == 0 except Exception as exc: result["error"] = f"execute probe failed: {exc}" @@ -170,7 +164,6 @@ def _probe_directory_access(path): return result - def _resolve_trusted_dir(config): allowlist = config.get("allowlist_trusted_dirs", []) candidates = _get_builtin_trusted_candidates() @@ -189,9 +182,7 @@ def _resolve_trusted_dir(config): try: probe = _probe_directory_access(candidate) probes[key] = probe - if selected is None and all( - probe.get(k) for k in ("create_ok", "write_ok", "read_ok", "execute_ok") - ): + if selected is None and all(probe.get(k) for k in ("create_ok", "write_ok", "read_ok", "execute_ok")): selected = candidate except Exception as exc: probes[key] = {"error": f"{type(exc).__name__}: {exc}"} @@ -200,7 +191,6 @@ def _resolve_trusted_dir(config): selected = BASE_DIR return selected, candidates, probes - def _sync_config_to_trusted(config_path, trusted_dir): trusted_cfg = trusted_dir / "launcher_config.json" sync_mode = "copy" @@ -224,7 +214,6 @@ def _sync_config_to_trusted(config_path, trusted_dir): sync_mode = "disabled" return trusted_cfg, sync_mode - def _set_runtime_base_dir(new_base_dir): global BASE_DIR, EMBEDDED_DIR, EMBEDDED_PYTHON, PORTABLEMC_BIN_DIR BASE_DIR = Path(new_base_dir) @@ -232,12 +221,10 @@ def _set_runtime_base_dir(new_base_dir): EMBEDDED_PYTHON = EMBEDDED_DIR / "python.exe" PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" - def update_launcher_state(**updates): LAUNCHER_STATE.update(updates) _safe_json_dump(CONFIG_PATH, LAUNCHER_STATE) - def initialize_runtime_configuration(): global LAUNCHER_STATE, ACTIVE_TRUSTED_DIR loaded = _load_json_file(CONFIG_PATH) @@ -261,50 +248,47 @@ def initialize_runtime_configuration(): LAUNCHER_STATE["config_sync_mode"] = sync_mode _safe_json_dump(CONFIG_PATH, LAUNCHER_STATE) - # --- OS and architecture detection (for portablemc binary) --- SYSTEM = platform.system().lower() MACHINE = platform.machine().lower() ARCH_MAP = { - "x86_64": "x86_64", - "amd64": "x86_64", - "i686": "i686", - "i386": "i686", - "aarch64": "aarch64", - "arm64": "aarch64", - "armv7l": "arm-gnueabihf", - "arm": "arm-gnueabihf", + 'x86_64': 'x86_64', + 'amd64': 'x86_64', + 'i686': 'i686', + 'i386': 'i686', + 'aarch64': 'aarch64', + 'arm64': 'aarch64', + 'armv7l': 'arm-gnueabihf', + 'arm': 'arm-gnueabihf', } OS_MAP = { - "windows": "windows", - "linux": "linux", - "darwin": "macos", + 'windows': 'windows', + 'linux': 'linux', + 'darwin': 'macos', } - def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, "x86_64") - if os_name == "macos": - arch = "aarch64" if arch == "aarch64" else "x86_64" - if os_name == "linux" and arch not in ("arm-gnueabihf",): - arch += "-gnu" - base = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/" - if os_name == "windows": + arch = ARCH_MAP.get(MACHINE, 'x86_64') + if os_name == 'macos': + arch = 'aarch64' if arch == 'aarch64' else 'x86_64' + if os_name == 'linux' and arch not in ('arm-gnueabihf',): + arch += '-gnu' + base = f"{PORTABLEMC_RELEASE_BASE}/" + if os_name == 'windows': ext = "zip" - filename = f"portablemc-5.0.2-{os_name}-{arch}-msvc.{ext}" + filename = f"portablemc-{PORTABLEMC_VERSION}-{os_name}-{arch}-msvc.{ext}" else: ext = "tar.gz" - filename = f"portablemc-5.0.2-{os_name}-{arch}.{ext}" + filename = f"portablemc-{PORTABLEMC_VERSION}-{os_name}-{arch}.{ext}" return base + filename - # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -336,7 +320,6 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") - # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -346,7 +329,6 @@ def is_junction(path): except OSError: return False - def create_junction(source, target): """ Create a junction from source to target. @@ -358,9 +340,7 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print( - f"⚠️ Source and target are the same ({source_path}); skipping junction creation." - ) + print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") # Still ensure the target directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -383,9 +363,7 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, - capture_output=True, - text=True, + check=True, capture_output=True, text=True ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") links = LAUNCHER_STATE.get("managed_links", {}) @@ -393,22 +371,93 @@ def create_junction(source, target): update_launcher_state(managed_links=links) return True except subprocess.CalledProcessError as e: - print( - f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" - ) + print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") target_path.mkdir(parents=True, exist_ok=True) return False - def ensure_junctions(): - r"""Ensure mods and resourcepacks directories exist in %LOCALAPPDATA%\PortableMC.""" - appdata = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) - base_dir = appdata / "PortableMC" + r"""Ensure mods/resourcepacks links target active runtime base directory.""" + base_dir = ACTIVE_TRUSTED_DIR or BASE_DIR base_dir.mkdir(parents=True, exist_ok=True) create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") +def _run_harness_probe(name, cmd, cwd=None, timeout=8): + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd) # nosec + return { + "name": name, + "command": cmd, + "returncode": result.returncode, + "stdout": (result.stdout or "").strip(), + "stderr": (result.stderr or "").strip(), + } + except Exception as exc: + return { + "name": name, + "command": cmd, + "error": f"{type(exc).__name__}: {exc}", + } + +def run_restricted_env_test_harness(): + """Run non-invasive restricted-environment compatibility probes.""" + print("\n=== Restricted Environment Test Harness ===\n") + stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + harness_log = PROJECT_LOGS_DIR / f"restricted_env_harness_{stamp}.log" + probes = [] + + probes.append(_run_harness_probe("whoami", ["whoami"])) + probes.append(_run_harness_probe("where_msbuild", ["where", "MSBuild.exe"])) + probes.append(_run_harness_probe("where_powershell", ["where", "powershell.exe"])) + probes.append(_run_harness_probe("where_cscript", ["where", "cscript.exe"])) + probes.append(_run_harness_probe("where_csc", ["where", "csc.exe"])) + probes.append(_run_harness_probe("where_py", ["where", "py"])) + probes.append(_run_harness_probe("where_python", ["where", "python"])) + probes.append(_run_harness_probe("msbuild_version", ["cmd", "/c", "MSBuild.exe -version"])) + probes.append(_run_harness_probe("powershell_version", ["powershell", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"])) + probes.append(_run_harness_probe("cscript_help", ["cscript", "//?"])) + probes.append(_run_harness_probe("csc_help", ["csc", "/help"])) + + trusted_dir = ACTIVE_TRUSTED_DIR or BASE_DIR + probes.append(_run_harness_probe("trusted_dir_probe", ["cmd", "/c", "cd"], cwd=trusted_dir)) + + lines = [] + lines.append("=" * 80) + lines.append("RESTRICTED ENVIRONMENT TEST HARNESS") + lines.append("=" * 80) + lines.append(f"timestamp: {datetime.datetime.now().isoformat()}") + lines.append(f"root_dir: {ROOT_DIR}") + lines.append(f"base_dir: {BASE_DIR}") + lines.append(f"active_trusted_dir: {ACTIVE_TRUSTED_DIR}") + lines.append(f"config_path: {CONFIG_PATH}") + lines.append("") + lines.append("[trusted_dir_probes]") + lines.append(json.dumps(LAUNCHER_STATE.get("trusted_dir_probes", {}), indent=2, sort_keys=True)) + lines.append("") + lines.append("[command_probes]") + for probe in probes: + lines.append("-" * 60) + lines.append(json.dumps(probe, indent=2)) + lines.append("-" * 60) + + try: + PROJECT_LOGS_DIR.mkdir(parents=True, exist_ok=True) + with open(harness_log, "w", encoding="utf-8", errors="replace") as f: + f.write("\n".join(lines)) + if ACTIVE_TRUSTED_DIR: + try: + mirror_logs = ACTIVE_TRUSTED_DIR / "logs" + mirror_logs.mkdir(parents=True, exist_ok=True) + shutil.copy2(harness_log, mirror_logs / harness_log.name) + except Exception: + pass + print(f"✅ Harness complete. Log: {harness_log}") + update_launcher_state(last_harness_log=str(harness_log)) + return True + except Exception as exc: + print(f"❌ Harness failed to write log: {exc}") + return False def remove_managed_link(target): target_path = Path(target) @@ -416,9 +465,7 @@ def remove_managed_link(target): if not target_path.exists() and not target_path.is_symlink(): return True if target_path.is_symlink() or is_junction(target_path): - os.rmdir(str(target_path)) if target_path.is_dir() else target_path.unlink( - missing_ok=True - ) + os.rmdir(str(target_path)) if target_path.is_dir() else target_path.unlink(missing_ok=True) elif target_path.is_dir(): shutil.rmtree(target_path, ignore_errors=True) else: @@ -428,7 +475,6 @@ def remove_managed_link(target): print(f"⚠️ Failed to remove {target_path}: {exc}") return False - def run_debug_menu(): while True: print("\n" + "=" * 60) @@ -440,6 +486,8 @@ def run_debug_menu(): print("4) Clear trusted mirror cache/logs") print("5) Print effective config") print("6) Run trusted-dir probes only") + print("7) Advanced restricted-env test harness") + print("8) Toggle sensitive env fields in dumps") print("b) Back") choice = input("Select debug option: ").strip().lower() @@ -448,10 +496,7 @@ def run_debug_menu(): elif choice == "2": remove_managed_link(BASE_DIR / "resourcepacks") elif choice == "3": - if ( - input("Confirm remove all managed links? (yes/no): ").strip().lower() - == "yes" - ): + if input("Confirm remove all managed links? (yes/no): ").strip().lower() == "yes": links = list(LAUNCHER_STATE.get("managed_links", {}).keys()) for link in links: remove_managed_link(link) @@ -475,23 +520,25 @@ def run_debug_menu(): selected, candidates, probes = _resolve_trusted_dir(LAUNCHER_STATE) print(f"Selected trusted dir: {selected}") print(json.dumps({str(k): v for k, v in probes.items()}, indent=2)) + elif choice == "7": + run_restricted_env_test_harness() + elif choice == "8": + current = bool(LAUNCHER_STATE.get("include_sensitive_env_details", False)) + update_launcher_state(include_sensitive_env_details=not current) + print(f"Sensitive env fields in dumps: {not current}") elif choice in ("b", "q"): return else: print("Invalid debug choice.") - # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print( - "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." - ) + print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") return ssl._create_unverified_context() return None - def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -504,7 +551,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, "wb") as f: + with open(dest_path, 'wb') as f: f.write(response.read()) return True except Exception as e2: @@ -512,12 +559,9 @@ def download_file(url, dest_path): return False else: print("❌ Download failed and insecure SSL is disabled.") - print( - "⚠️ If you are in a restricted network, set ALLOW_INSECURE_SSL=true and try again." - ) + print("⚠️ If you are in a restricted network, set ALLOW_INSECURE_SSL=true and try again.") return False - # --- Helper functions --- def find_msbuild_candidates(): """Return a list of candidate MSBuild.exe paths sorted by priority (highest first).""" @@ -527,21 +571,10 @@ def find_msbuild_candidates(): vswhere = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" if os.path.isfile(vswhere): try: - result = subprocess.run( - [ - vswhere, - "-latest", - "-products", - "*", - "-find", - "MSBuild\\**\\Bin\\MSBuild.exe", - ], - capture_output=True, - text=True, - timeout=5, - ) + result = subprocess.run([vswhere, "-latest", "-products", "*", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"], + capture_output=True, text=True, timeout=5) if result.returncode == 0: - for line in result.stdout.strip().split("\n"): + for line in result.stdout.strip().split('\n'): if line and os.path.isfile(line): candidates.append((line, 100)) # highest priority except Exception: @@ -550,76 +583,33 @@ def find_msbuild_candidates(): # 2. Hard‑coded candidates with descending priorities hardcoded = [ # Visual Studio 2026 (v18.0) - ( - r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", - 99, - ), + (r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", 99), + (r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 99), + # Visual Studio 2022 (v17.0) - ( - r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), - ( - r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", - 90, - ), + (r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", 90), + (r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 90), + # Visual Studio 2019 (v16.0) - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", - 80, - ), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", 80), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 80), + # Visual Studio 2017 (v15.0) - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), - ( - r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", - 70, - ), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", 70), + (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", 70), + # Standalone Build Tools (v14.0, v12.0) (r"C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe", 60), (r"C:\Program Files (x86)\MSBuild\12.0\Bin\MSBuild.exe", 50), + # .NET Framework (64‑bit preferred, then 32‑bit) (r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe", 40), (r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe", 30), @@ -639,7 +629,6 @@ def find_msbuild_candidates(): sorted_candidates = sorted(unique.items(), key=lambda x: x[1], reverse=True) return [path for path, _ in sorted_candidates] - def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" BASE_DIR.mkdir(parents=True, exist_ok=True) @@ -659,7 +648,6 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True - def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -678,16 +666,11 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True - def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run( - [str(EMBEDDED_PYTHON), "--version"], - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -701,7 +684,6 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False - def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -718,10 +700,8 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, - capture_output=True, - text=True, - ) # nosec + env=env_check, capture_output=True, text=True + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -730,18 +710,14 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True - def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" print("📦 Installing portablemc (uv first, pip fallback)...") - if not install_packages_with_fallback( - ["portablemc"], python_exe=EMBEDDED_PYTHON, isolated=True - ): + if not install_packages_with_fallback(["portablemc"], python_exe=EMBEDDED_PYTHON, isolated=True): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) - def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -753,7 +729,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, "wb") as out_file: + with open(pip_script, 'wb') as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -761,7 +737,6 @@ def download_get_pip(): return None return pip_script - def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -771,7 +746,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -779,7 +754,6 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True - def run_uv_install(packages, python_exe=None, user=False): """Try package install using uv. Returns True on success.""" if python_exe is None: @@ -795,6 +769,8 @@ def run_uv_install(packages, python_exe=None, user=False): return False if result.returncode != 0: print(f"⚠️ uv install failed ({result.returncode}); falling back to pip.") + if result.stdout: + print(result.stdout.strip()) if result.stderr: print(result.stderr.strip()) return False @@ -802,10 +778,7 @@ def run_uv_install(packages, python_exe=None, user=False): print(result.stdout.strip()) return True - -def install_packages_with_fallback( - packages, python_exe=None, isolated=True, user=False -): +def install_packages_with_fallback(packages, python_exe=None, isolated=True, user=False): """Install packages with uv first, then pip fallback.""" if python_exe is None: python_exe = EMBEDDED_PYTHON @@ -822,6 +795,24 @@ def install_packages_with_fallback( update_launcher_state(installer_backend="pip") return ok +def run_portablemc_with_uvx_fallback(portablemc_args, env=None, cwd=None, python_exe=None): + """Run portablemc with uvx first, python module fallback.""" + uvx_cmd = ["uvx", "portablemc"] + portablemc_args + print(f"🚀 Launching (uvx): {' '.join(uvx_cmd)}") + try: + uvx_code, uvx_output = run_command_live(uvx_cmd, env=env, cwd=cwd) + if uvx_code == 0: + return uvx_code, uvx_output, uvx_cmd + print(f"⚠️ uvx failed with code {uvx_code}, falling back to python module.") + except Exception as exc: + print(f"⚠️ uvx blocked/unavailable: {exc}. Falling back to python module.") + + if python_exe is None: + python_exe = EMBEDDED_PYTHON + py_cmd = [str(python_exe), "-m", "portablemc"] + portablemc_args + print(f"🚀 Launching (python fallback): {' '.join(py_cmd)}") + py_code, py_output = run_command_live(py_cmd, env=env, cwd=cwd) + return py_code, py_output, py_cmd def install_pip(python_exe=None): """Install pip into the given Python environment.""" @@ -834,13 +825,10 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [ - str(python_exe), - str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org", - ] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [str(python_exe), str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org"] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -848,26 +836,20 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True - def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not install_packages_with_fallback( - ["--upgrade", "pip"], isolated=True, python_exe=python_exe - ): + if not install_packages_with_fallback(["--upgrade", "pip"], isolated=True, python_exe=python_exe): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not install_packages_with_fallback( - [pkg], isolated=True, python_exe=python_exe - ): + if not install_packages_with_fallback([pkg], isolated=True, python_exe=python_exe): print(f"❌ Failed to install {pkg}.") return False return True - def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -875,11 +857,9 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, - text=True, - check=True, - env={"PYTHONNOUSERSITE": "1"}, - ) # nosec + capture_output=True, text=True, check=True, + env={"PYTHONNOUSERSITE": "1"} + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -887,7 +867,6 @@ def get_certifi_path(python_exe=None): pass return None - def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -921,9 +900,8 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True - def test_portablemc(python_exe=None): - """Check if portablemc is available (binary or module). Returns 'binary' or 'module' or None.""" + """Check if portablemc is available (binary, uvx, or module).""" # Try binary first exe_name = "portablemc.exe" if SYSTEM == "windows" else "portablemc" binary_path = PORTABLEMC_BIN_DIR / exe_name @@ -933,19 +911,23 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run( - [str(binary_path), "--help"], - env=env, - capture_output=True, - text=True, - timeout=5, - ) # nosec + result = subprocess.run([str(binary_path), "--help"], env=env, + capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" except (subprocess.TimeoutExpired, FileNotFoundError): print("⏱️ portablemc binary check timed out or not found, trying module.") + # Try uvx + try: + result = subprocess.run(["uvx", "portablemc", "--help"], capture_output=True, text=True, timeout=8) # nosec + if result.returncode == 0: + print("✅ portablemc via uvx works.") + return "uvx" + except Exception: + pass + # Try module if python_exe is None: python_exe = EMBEDDED_PYTHON @@ -954,7 +936,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -962,7 +944,6 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None - def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -974,17 +955,16 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc (uv first, pip fallback)...") - if install_packages_with_fallback( - ["portablemc"], isolated=True, python_exe=python_exe - ): + if install_packages_with_fallback(["portablemc"], isolated=True, python_exe=python_exe): method = test_portablemc(python_exe) if method: return method return None - def collect_system_details(): """Collect diagnostic details. Individual probes can fail safely.""" + include_sensitive = bool(LAUNCHER_STATE.get("include_sensitive_env_details", False)) + sensitive_keys = {"USERNAME", "USERDOMAIN", "COMPUTERNAME"} details = { "timestamp": datetime.datetime.now().isoformat(), "platform": platform.platform(), @@ -1002,20 +982,16 @@ def collect_system_details(): "project_logs_dir": str(PROJECT_LOGS_DIR), "config_path": str(CONFIG_PATH), "env_subset": { - k: os.environ.get(k, "") + k: ( + os.environ.get(k, "") + if include_sensitive or k not in sensitive_keys + else "" + ) for k in [ - "USERNAME", - "USERDOMAIN", - "COMPUTERNAME", - "PROCESSOR_ARCHITECTURE", - "LOCALAPPDATA", - "APPDATA", - "TEMP", - "TMP", - "COMSPEC", - "PSModulePath", + "USERNAME", "USERDOMAIN", "COMPUTERNAME", "PROCESSOR_ARCHITECTURE", + "LOCALAPPDATA", "APPDATA", "TEMP", "TMP", "COMSPEC", "PSModulePath" ] - }, + } } probes = { @@ -1039,10 +1015,7 @@ def collect_system_details(): details["probes"][name] = {"error": f"{type(exc).__name__}: {exc}"} return details - -def write_failure_dump( - kind, message, command=None, output=None, returncode=None, exc=None, extra=None -): +def write_failure_dump(kind, message, command=None, output=None, returncode=None, exc=None, extra=None): """Write a resilient failure dump log and return its path (or None).""" try: logs_dir = PROJECT_LOGS_DIR @@ -1079,9 +1052,7 @@ def write_failure_dump( lines.append("[Stack Trace]") if exc is not None: - lines.append( - "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) - ) + lines.append("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) else: lines.append("(no Python exception captured)") @@ -1100,7 +1071,6 @@ def write_failure_dump( print(f"⚠️ Failed to write diagnostic dump: {dump_exc}") return None - def run_command_live(cmd, env=None, cwd=None): """Run a command, stream output live, and capture it for diagnostics.""" output_lines = [] @@ -1112,7 +1082,6 @@ def run_command_live(cmd, env=None, cwd=None): stderr=subprocess.STDOUT, text=True, bufsize=1, - universal_newlines=True, ) try: for line in process.stdout: @@ -1127,12 +1096,11 @@ def run_command_live(cmd, env=None, cwd=None): pass raise - # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -1173,18 +1141,10 @@ def add_candidate(p): # 4. Common install locations for ver in PYTHON_VERSIONS: - num = ver.replace(".", "") - for base in ( - r"C:\Python{}", - r"C:\Program Files\Python{}", - r"C:\Program Files (x86)\Python{}", - ): + num = ver.replace('.', '') + for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): add_candidate(base.format(num) + "\\python.exe") - user_dir = ( - Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) - / "Programs" - / f"Python{num}" - ) + user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" add_candidate(user_dir / "python.exe") add_candidate(r"C:\Windows\Sysnative\python.exe") add_candidate(r"C:\Windows\System32\python.exe") @@ -1193,12 +1153,10 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run( - [str(p), "--version"], capture_output=True, text=True, timeout=2 - ) # nosec + result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: - match = re.search(r"\d+(?:\.\d+)+", combined) + match = re.search(r'\d+(?:\.\d+)+', combined) if match: version_str = match.group() valid.append((version_str, p)) @@ -1208,12 +1166,11 @@ def add_candidate(p): if not valid: return None - valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best - # --- Launcher functions --- def launch_launcher(method, python_exe=None, extra_env=None): launcher_script = PORTABLEMC_PY @@ -1260,7 +1217,6 @@ def launch_launcher(method, python_exe=None, extra_env=None): print("⏹️ Interrupted by user.") return True - def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -1298,14 +1254,10 @@ def run_web_launcher(): print("📦 Installing required packages with system Python...") for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") - if not install_packages_with_fallback( - [pkg], python_exe=sys_python, isolated=False, user=True - ): + if not install_packages_with_fallback([pkg], python_exe=sys_python, isolated=False, user=True): print(f"❌ Failed to install {pkg}.") return False - print( - f" ✅ {pkg} installed via {LAUNCHER_STATE.get('installer_backend', 'pip')}." - ) + print(f" ✅ {pkg} installed via {LAUNCHER_STATE.get('installer_backend', 'pip')}.") # Test portablemc with system Python method = test_portablemc(sys_python) @@ -1318,11 +1270,10 @@ def run_web_launcher(): extra_env = { "PYTHONUSERBASE": str(launcher_python_dir), "PYTHONNOUSERSITE": "0", - "PATH": os.environ["PATH"], # keep the original PATH + "PATH": os.environ["PATH"] # keep the original PATH } return launch_launcher(method, sys_python, extra_env) - def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") prepare_user_data() @@ -1336,6 +1287,9 @@ def run_msbuild_launcher(): print(f"Trying MSBuild at: {msbuild_path}") env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" + env["PORTABLEMC_VERSION"] = PORTABLEMC_VERSION + env["PORTABLEMC_RELEASE_BASE"] = PORTABLEMC_RELEASE_BASE + env["LAUNCHER_VERBOSE"] = env.get("LAUNCHER_VERBOSE", "0") if ALLOW_INSECURE_SSL: env["ALLOW_INSECURE_SSL"] = "true" @@ -1347,7 +1301,7 @@ def run_msbuild_launcher(): f"/p:JvmOpts={DEFAULT_JVM_OPTS}", "/p:UsePowerShell=true", "/p:UseCsc=false", - "/p:UseVbs=false", + "/p:UseVbs=false" ] print(f"Executing: {' '.join(cmd)}") try: @@ -1362,13 +1316,11 @@ def run_msbuild_launcher(): command=cmd, output=output, returncode=result_code, - extra={"candidate": msbuild_path}, + extra={"candidate": msbuild_path} ) if dump: print(f"📝 Failure dump written: {dump}") - print( - f"⚠️ MSBuild at {msbuild_path} exited with code {result_code}. Trying next candidate." - ) + print(f"⚠️ MSBuild at {msbuild_path} exited with code {result_code}. Trying next candidate.") except KeyboardInterrupt: raise except Exception as e: @@ -1377,47 +1329,34 @@ def run_msbuild_launcher(): message=f"Exception while running MSBuild candidate: {msbuild_path}", command=cmd, exc=e, - extra={"candidate": msbuild_path}, + extra={"candidate": msbuild_path} ) if dump: print(f"📝 Failure dump written: {dump}") - print( - f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate." - ) + print(f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate.") print("❌ All MSBuild candidates failed.") return False - def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") prepare_user_data() - failure_context = { - "mode": "cli", - "server": DEFAULT_SERVER_IP, - "username": DEFAULT_USERNAME, - } + failure_context = {"mode": "cli", "server": DEFAULT_SERVER_IP, "username": DEFAULT_USERNAME} # Try embedded Python first if setup_embedded_python(): # Install portablemc (and certifi for SSL) into embedded Python method = install_portablemc_via_embedded() if not method: - dump = write_failure_dump( - "cli", - "Embedded Python portablemc install failed.", - extra=failure_context, - ) + dump = write_failure_dump("cli", "Embedded Python portablemc install failed.", extra=failure_context) if dump: print(f"📝 Failure dump written: {dump}") return False # Install certifi to get CA bundle print("📦 Installing certifi for SSL support...") - if not install_packages_with_fallback( - ["certifi"], isolated=True, python_exe=EMBEDDED_PYTHON - ): + if not install_packages_with_fallback(["certifi"], isolated=True, python_exe=EMBEDDED_PYTHON): print("⚠️ Failed to install certifi; SSL errors may occur.") else: print("✅ certifi installed.") @@ -1428,25 +1367,18 @@ def run_cli_launcher(): # Create junctions (if not already done) ensure_junctions() - # Build CLI arguments (module syntax) + # Build CLI arguments (portablemc syntax) jvm_arg_tokens = [arg for arg in DEFAULT_JVM_OPTS.split() if arg.strip()] - cmd = [ - str(EMBEDDED_PYTHON), - "-m", - "portablemc", - "--main-dir", - ".", - "--output", - "human-color", + portablemc_args = [ + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, + "--server", DEFAULT_SERVER_IP, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] for token in reversed(jvm_arg_tokens): - cmd.insert(9, f"--jvm-arg={token}") + portablemc_args.insert(7, f"--jvm-arg={token}") env = os.environ.copy() env["__COMPAT_LAYER"] = "RUNASINVOKER" @@ -1457,17 +1389,18 @@ def run_cli_launcher(): env["SSL_CERT_FILE"] = cert_path env["REQUESTS_CA_BUNDLE"] = cert_path - print(f"🚀 Launching: {' '.join(cmd)}") try: - result_code, output = run_command_live(cmd, env=env, cwd=BASE_DIR) + result_code, output, used_cmd = run_portablemc_with_uvx_fallback( + portablemc_args, env=env, cwd=BASE_DIR, python_exe=EMBEDDED_PYTHON + ) if result_code != 0: dump = write_failure_dump( "cli", "Embedded Python CLI launch failed.", - command=cmd, + command=used_cmd, output=output, returncode=result_code, - extra=failure_context, + extra=failure_context ) if dump: print(f"📝 Failure dump written: {dump}") @@ -1476,13 +1409,7 @@ def run_cli_launcher(): except KeyboardInterrupt: raise except Exception as e: - dump = write_failure_dump( - "cli", - "Exception during embedded CLI launch.", - command=cmd, - exc=e, - extra=failure_context, - ) + dump = write_failure_dump("cli", "Exception during embedded CLI launch.", command=cmd, exc=e, extra=failure_context) if dump: print(f"📝 Failure dump written: {dump}") print(f"❌ CLI launcher exited with error: {e}") @@ -1492,9 +1419,7 @@ def run_cli_launcher(): print("\n⚠️ Embedded Python not usable. Trying system Python...") sys_python = get_system_python() if not sys_python: - dump = write_failure_dump( - "cli", "System Python not found for CLI fallback.", extra=failure_context - ) + dump = write_failure_dump("cli", "System Python not found for CLI fallback.", extra=failure_context) if dump: print(f"📝 Failure dump written: {dump}") print("❌ No system Python found. Cannot proceed.") @@ -1511,22 +1436,12 @@ def run_cli_launcher(): # Install portablemc and certifi with system Python print("📦 Installing portablemc with system Python...") - if not install_packages_with_fallback( - ["portablemc", "certifi"], python_exe=sys_python, isolated=False, user=True - ): + if not install_packages_with_fallback(["portablemc", "certifi"], python_exe=sys_python, isolated=False, user=True): dump = write_failure_dump( "cli", "package install failed in system Python fallback (uv and pip).", - command=[ - str(sys_python), - "-m", - "pip/uv", - "install", - "--user", - "portablemc", - "certifi", - ], - extra=failure_context, + command=[str(sys_python), "-m", "pip/uv", "install", "--user", "portablemc", "certifi"], + extra=failure_context ) if dump: print(f"📝 Failure dump written: {dump}") @@ -1535,11 +1450,7 @@ def run_cli_launcher(): method = test_portablemc(sys_python) if not method: - dump = write_failure_dump( - "cli", - "portablemc unavailable after system Python install.", - extra=failure_context, - ) + dump = write_failure_dump("cli", "portablemc unavailable after system Python install.", extra=failure_context) if dump: print(f"📝 Failure dump written: {dump}") print("❌ portablemc not available after installation.") @@ -1550,40 +1461,34 @@ def run_cli_launcher(): ensure_junctions() jvm_arg_tokens = [arg for arg in DEFAULT_JVM_OPTS.split() if arg.strip()] - cmd = [ - str(sys_python), - "-m", - "portablemc", - "--main-dir", - ".", - "--output", - "human-color", + portablemc_args = [ + "--main-dir", ".", + "--output", "human-color", "start", - "--server", - DEFAULT_SERVER_IP, + "--server", DEFAULT_SERVER_IP, "fabric:", - "-u", - DEFAULT_USERNAME, + "-u", DEFAULT_USERNAME ] for token in reversed(jvm_arg_tokens): - cmd.insert(9, f"--jvm-arg={token}") + portablemc_args.insert(7, f"--jvm-arg={token}") env["__COMPAT_LAYER"] = "RUNASINVOKER" env["LAUNCHER_ROOT"] = str(ROOT_DIR) if cert_path: env["SSL_CERT_FILE"] = cert_path env["REQUESTS_CA_BUNDLE"] = cert_path - print(f"🚀 Launching: {' '.join(cmd)}") try: - result_code, output = run_command_live(cmd, env=env, cwd=BASE_DIR) + result_code, output, used_cmd = run_portablemc_with_uvx_fallback( + portablemc_args, env=env, cwd=BASE_DIR, python_exe=sys_python + ) if result_code != 0: dump = write_failure_dump( "cli", "System Python CLI launch failed.", - command=cmd, + command=used_cmd, output=output, returncode=result_code, - extra=failure_context, + extra=failure_context ) if dump: print(f"📝 Failure dump written: {dump}") @@ -1592,19 +1497,12 @@ def run_cli_launcher(): except KeyboardInterrupt: raise except Exception as e: - dump = write_failure_dump( - "cli", - "Exception during system Python CLI launch.", - command=cmd, - exc=e, - extra=failure_context, - ) + dump = write_failure_dump("cli", "Exception during system Python CLI launch.", command=cmd, exc=e, extra=failure_context) if dump: print(f"📝 Failure dump written: {dump}") print(f"❌ CLI launcher exited with error: {e}") return False - def main(): initialize_runtime_configuration() # Display menu @@ -1636,12 +1534,9 @@ def main(): print("Invalid choice. Exiting.") sys.exit(1) - update_launcher_state( - success=bool(success), last_error="" if success else "launcher returned failure" - ) + update_launcher_state(success=bool(success), last_error="" if success else "launcher returned failure") sys.exit(0 if success else 1) - if __name__ == "__main__": try: main() diff --git a/scripts/Launcher.ps1 b/scripts/Launcher.ps1 index dc56d72..d4f5ccd 100644 --- a/scripts/Launcher.ps1 +++ b/scripts/Launcher.ps1 @@ -14,9 +14,12 @@ if (-not $JvmOpts -and $args.Count -gt 0) { $JvmOpts = $args[0] } -Write-Host "DEBUG: Username = '$Username'" -Write-Host "DEBUG: ServerIp = '$ServerIp'" -Write-Host "DEBUG: JvmOpts = '$JvmOpts'" +$verbose = $env:LAUNCHER_VERBOSE -in @("true", "1", "yes", "on") +if ($verbose) { + Write-Host "DEBUG: Username = '$Username'" + Write-Host "DEBUG: ServerIp = '$ServerIp'" + Write-Host "DEBUG: JvmOpts = '$JvmOpts'" +} if (-not $JvmOpts) { Write-Host "ERROR: JvmOpts is empty. Cannot proceed." exit 1 @@ -37,11 +40,13 @@ if (-not (Test-Path $baseDir)) { # --- Download portablemc.exe if missing (with optional SSL fallback) --- if (-not (Test-Path $exePath)) { Write-Host "portablemc.exe not found. Downloading to $binDir..." - $url = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/portablemc-5.0.2-windows-x86_64-msvc.zip" + $pmcVersion = if ($env:PORTABLEMC_VERSION) { $env:PORTABLEMC_VERSION } else { "5.0.2" } + $releaseBase = if ($env:PORTABLEMC_RELEASE_BASE) { $env:PORTABLEMC_RELEASE_BASE } else { "https://github.com/mindstorm38/portablemc/releases/download/v$pmcVersion" } + $url = "$releaseBase/portablemc-$pmcVersion-windows-x86_64-msvc.zip" $zipPath = Join-Path $baseDir "portablemc.zip" # Check if insecure SSL is allowed - $allowInsecure = $env:ALLOW_INSECURE_SSL -in @("true", "1", "yes") + $allowInsecure = $env:ALLOW_INSECURE_SSL -in @("true", "1", "yes", "on") try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 @@ -133,4 +138,4 @@ try { exit 1 } finally { Pop-Location -} +} \ No newline at end of file diff --git a/scripts/Launcher.targets b/scripts/Launcher.targets index 3bdde96..ab4f174 100644 --- a/scripts/Launcher.targets +++ b/scripts/Launcher.targets @@ -82,4 +82,4 @@ - + \ No newline at end of file diff --git a/scripts/Launcher.vbs b/scripts/Launcher.vbs index bbfc53f..bc974b8 100644 --- a/scripts/Launcher.vbs +++ b/scripts/Launcher.vbs @@ -36,7 +36,8 @@ Function DownloadFile(url, destPath) Set shell = CreateObject("WScript.Shell") allowInsecure = LCase(shell.Environment("PROCESS")("ALLOW_INSECURE_SSL")) = "true" Or _ LCase(shell.Environment("PROCESS")("ALLOW_INSECURE_SSL")) = "1" Or _ - LCase(shell.Environment("PROCESS")("ALLOW_INSECURE_SSL")) = "yes" + LCase(shell.Environment("PROCESS")("ALLOW_INSECURE_SSL")) = "yes" Or _ + LCase(shell.Environment("PROCESS")("ALLOW_INSECURE_SSL")) = "on" On Error Resume Next Set http = CreateObject("WinHttp.WinHttpRequest.5.1") @@ -91,7 +92,11 @@ End Sub ' --- Main download and extraction --- If Not fso.FileExists(exePath) Then WScript.Echo "portablemc.exe not found. Downloading..." - url = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/portablemc-5.0.2-windows-x86_64-msvc.zip" + pmcVersion = shell.Environment("PROCESS")("PORTABLEMC_VERSION") + If pmcVersion = "" Then pmcVersion = "5.0.2" + releaseBase = shell.Environment("PROCESS")("PORTABLEMC_RELEASE_BASE") + If releaseBase = "" Then releaseBase = "https://github.com/mindstorm38/portablemc/releases/download/v" & pmcVersion + url = releaseBase & "/portablemc-" & pmcVersion & "-windows-x86_64-msvc.zip" zipPath = fso.BuildPath(baseDir, "portablemc.zip") If Not DownloadFile(url, zipPath) Then WScript.Echo "Download failed. Exiting." @@ -162,4 +167,4 @@ Else Wend WScript.Echo "Process exited with code " & proc.ExitCode WScript.Quit proc.ExitCode -End If +End If \ No newline at end of file diff --git a/scripts/PortableMCLoader.cs b/scripts/PortableMCLoader.cs index 27d6f20..ba0ba29 100644 --- a/scripts/PortableMCLoader.cs +++ b/scripts/PortableMCLoader.cs @@ -29,7 +29,11 @@ static void Main(string[] args) if (!File.Exists(exePath)) { Console.WriteLine("portablemc.exe not found. Downloading..."); - string url = "https://github.com/mindstorm38/portablemc/releases/download/v5.0.2/portablemc-5.0.2-windows-x86_64-msvc.zip"; + string version = Environment.GetEnvironmentVariable("PORTABLEMC_VERSION"); + if (string.IsNullOrEmpty(version)) version = "5.0.2"; + string releaseBase = Environment.GetEnvironmentVariable("PORTABLEMC_RELEASE_BASE"); + if (string.IsNullOrEmpty(releaseBase)) releaseBase = "https://github.com/mindstorm38/portablemc/releases/download/v" + version; + string url = releaseBase + "/portablemc-" + version + "-windows-x86_64-msvc.zip"; string zipPath = Path.Combine(baseDir, "portablemc.zip"); if (!DownloadFile(url, zipPath)) @@ -100,7 +104,7 @@ static bool DownloadFile(string url, string destPath) { // Manual check for environment variable (C# 5 compatible) string envVar = Environment.GetEnvironmentVariable("ALLOW_INSECURE_SSL"); - bool allowInsecure = envVar != null && (envVar.ToLower() == "true" || envVar == "1" || envVar.ToLower() == "yes"); + bool allowInsecure = envVar != null && (envVar.ToLower() == "true" || envVar == "1" || envVar.ToLower() == "yes" || envVar.ToLower() == "on"); try { @@ -154,4 +158,4 @@ static void FlattenDirectory(string dir) Directory.Delete(subDir, true); } } -} +} \ No newline at end of file diff --git a/scripts/portablemc.py b/scripts/portablemc.py index b4956e4..6ef7c60 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -32,14 +32,12 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") - def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace("&", "&").replace("<", "<").replace(">", ">") - + return s.replace('&', '&').replace('<', '<').replace('>', '>') # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") +VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -58,7 +56,6 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil - PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -71,7 +68,6 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter - ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -927,38 +923,34 @@ def escape_html(s): """ - # --- ROUTES --- -@socketio.on("connect") +@socketio.on('connect') def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f"Client connected: {client_id} (Total: {connected_clients})") - emit("status", {"core": "online", "minecraft": "checking"}) - + print(f'Client connected: {client_id} (Total: {connected_clients})') + emit('status', {'core': 'online', 'minecraft': 'checking'}) -@socketio.on("disconnect") +@socketio.on('disconnect') def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f"Client disconnected: {client_id} (Total: {connected_clients})") + print(f'Client disconnected: {client_id} (Total: {connected_clients})') - -@socketio.on("ping_minecraft") +@socketio.on('ping_minecraft') def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit("minecraft_status", {"online": True}) + emit('minecraft_status', {'online': True}) except Exception: - emit("minecraft_status", {"online": False}) - + emit('minecraft_status', {'online': False}) @app.route("/ping") def ping(): @@ -971,7 +963,6 @@ def ping(): except Exception: return jsonify(online=False), 200 - @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -981,17 +972,15 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): - def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -1000,44 +989,31 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: - def error_gen(): - payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): - def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" ] for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: - def error_gen(): - payload = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1046,15 +1022,13 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m", + "\x1b[90mPlease close the game before launching again.\x1b[0m" ] - def error_gen(): for line in lines: - payload = json.dumps({"type": "ansi", "content": line}) + payload = json.dumps({'type': 'ansi', 'content': line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" - return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1069,9 +1043,7 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get( - "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") - ) + local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1127,15 +1099,13 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps( - {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} - ) + busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == "nt": + if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1151,7 +1121,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo, + startupinfo=startupinfo ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1163,12 +1133,7 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", - } - ) + disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1181,7 +1146,7 @@ def generate(): break continue - raw_line = line.rstrip("\n") + raw_line = line.rstrip('\n') now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1192,23 +1157,18 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert( - safe_line, full=False - ).strip() - payload = json.dumps({"type": "html", "content": html_line}) + html_line = ansi_converter.convert(safe_line, full=False).strip() + payload = json.dumps({'type': 'html', 'content': html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) else: - payload = json.dumps({"type": "ansi", "content": raw_line}) + payload = json.dumps({'type': 'ansi', 'content': raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if ( - current_file != last_progress - and (now - last_send_time) > update_interval - ): + if current_file != last_progress and (now - last_send_time) > update_interval: yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1222,21 +1182,14 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps( - { - "type": "ansi", - "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", - } - ) + payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps( - {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} - ) + payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) yield f"data: {payload}\n\n" except Exception: pass @@ -1251,18 +1204,8 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", - } - ) - tip_msg = json.dumps( - { - "type": "ansi", - "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", - } - ) + ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) + tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1275,12 +1218,10 @@ def generate(): response.call_on_close(closed_event.set) return response - @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) - def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1307,7 +1248,6 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") - def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1315,11 +1255,10 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) - signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) + graceful_shutdown(None, None) \ No newline at end of file From ac412a116f199c23c814c88fb4a89cef67484857 Mon Sep 17 00:00:00 2001 From: PythonChicken123 <101510622+PythonChicken123@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:44:09 +0100 Subject: [PATCH 69/70] Fix hardcoded paths and ruff linter --- main.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index d5fb97e..3e56467 100644 --- a/main.py +++ b/main.py @@ -418,6 +418,7 @@ def run_restricted_env_test_harness(): probes.append(_run_harness_probe("powershell_version", ["powershell", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"])) probes.append(_run_harness_probe("cscript_help", ["cscript", "//?"])) probes.append(_run_harness_probe("csc_help", ["csc", "/help"])) + probes.append(_run_harness_probe("systeminfo", ["systeminfo"])) trusted_dir = ACTIVE_TRUSTED_DIR or BASE_DIR probes.append(_run_harness_probe("trusted_dir_probe", ["cmd", "/c", "cd"], cwd=trusted_dir)) @@ -432,6 +433,11 @@ def run_restricted_env_test_harness(): lines.append(f"active_trusted_dir: {ACTIVE_TRUSTED_DIR}") lines.append(f"config_path: {CONFIG_PATH}") lines.append("") + lines.append("[effective_env]") + lines.append(f"PORTABLEMC_VERSION={os.environ.get('PORTABLEMC_VERSION', PORTABLEMC_VERSION)}") + lines.append(f"ALLOW_INSECURE_SSL={os.environ.get('ALLOW_INSECURE_SSL', '')}") + lines.append(f"LAUNCHER_VERBOSE={os.environ.get('LAUNCHER_VERBOSE', '')}") + lines.append("") lines.append("[trusted_dir_probes]") lines.append(json.dumps(LAUNCHER_STATE.get("trusted_dir_probes", {}), indent=2, sort_keys=True)) lines.append("") @@ -1409,7 +1415,13 @@ def run_cli_launcher(): except KeyboardInterrupt: raise except Exception as e: - dump = write_failure_dump("cli", "Exception during embedded CLI launch.", command=cmd, exc=e, extra=failure_context) + dump = write_failure_dump( + "cli", + "Exception during embedded CLI launch.", + command=used_cmd, + exc=e, + extra=failure_context, + ) if dump: print(f"📝 Failure dump written: {dump}") print(f"❌ CLI launcher exited with error: {e}") @@ -1497,7 +1509,13 @@ def run_cli_launcher(): except KeyboardInterrupt: raise except Exception as e: - dump = write_failure_dump("cli", "Exception during system Python CLI launch.", command=cmd, exc=e, extra=failure_context) + dump = write_failure_dump( + "cli", + "Exception during system Python CLI launch.", + command=used_cmd, + exc=e, + extra=failure_context, + ) if dump: print(f"📝 Failure dump written: {dump}") print(f"❌ CLI launcher exited with error: {e}") From 3e5dafb8633fd7905028283e8df560412f4c59c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 18:44:22 +0000 Subject: [PATCH 70/70] Apply automatic ruff fixes and formatting [skip ci] --- main.py | 491 +++++++++++++++++++++++++++++++----------- scripts/portablemc.py | 129 ++++++++--- 2 files changed, 462 insertions(+), 158 deletions(-) diff --git a/main.py b/main.py index 3e56467..a3ddd86 100644 --- a/main.py +++ b/main.py @@ -23,7 +23,7 @@ from pathlib import Path # --- Windows-only guard --- -if platform.system().lower() != 'windows': +if platform.system().lower() != "windows": print("❌ This bootstrap script currently only supports Windows.") sys.exit(1) @@ -37,15 +37,19 @@ EMBEDDED_DIR = BASE_DIR / "python" EMBEDDED_PYTHON = EMBEDDED_DIR / "python.exe" PORTABLEMC_VERSION = "5.0.2" -PORTABLEMC_RELEASE_BASE = f"https://github.com/mindstorm38/portablemc/releases/download/v{PORTABLEMC_VERSION}" +PORTABLEMC_RELEASE_BASE = ( + f"https://github.com/mindstorm38/portablemc/releases/download/v{PORTABLEMC_VERSION}" +) PYTHON_VERSION = "3.14.3" PYTHON_VERSIONS = ["3.15", "3.14", "3.13", "3.12", "3.11"] PYTHON_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] +BASE_PACKAGES = ["flask", "flask-socketio", "psutil", "ansi2html", "certifi"] PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"} -ALLOW_INSECURE_SSL = os.environ.get("ALLOW_INSECURE_SSL", "").strip().lower() in TRUTHY_ENV_VALUES +ALLOW_INSECURE_SSL = ( + os.environ.get("ALLOW_INSECURE_SSL", "").strip().lower() in TRUTHY_ENV_VALUES +) # Default game settings DEFAULT_USERNAME = "CubeUniform840" @@ -83,6 +87,7 @@ "include_sensitive_env_details": False, } + def _safe_json_dump(path, payload): try: path.parent.mkdir(parents=True, exist_ok=True) @@ -92,6 +97,7 @@ def _safe_json_dump(path, payload): except Exception: return False + def _load_json_file(path): try: if not path.exists(): @@ -102,6 +108,7 @@ def _load_json_file(path): except Exception: return {} + def _get_builtin_trusted_candidates(): user_profile = Path(os.environ.get("USERPROFILE", Path.home())) local_app = Path(os.environ.get("LOCALAPPDATA", user_profile / "AppData/Local")) @@ -119,6 +126,7 @@ def _get_builtin_trusted_candidates(): temp_dir / "PortableMC", ] + def _probe_directory_access(path): result = { "exists": False, @@ -152,7 +160,9 @@ def _probe_directory_access(path): result["error"] = f"read failed: {exc}" try: - proc = subprocess.run(["cmd", "/c", "cd"], cwd=path, capture_output=True, text=True, timeout=3) # nosec + proc = subprocess.run( + ["cmd", "/c", "cd"], cwd=path, capture_output=True, text=True, timeout=3 + ) # nosec result["execute_ok"] = proc.returncode == 0 except Exception as exc: result["error"] = f"execute probe failed: {exc}" @@ -164,6 +174,7 @@ def _probe_directory_access(path): return result + def _resolve_trusted_dir(config): allowlist = config.get("allowlist_trusted_dirs", []) candidates = _get_builtin_trusted_candidates() @@ -182,7 +193,9 @@ def _resolve_trusted_dir(config): try: probe = _probe_directory_access(candidate) probes[key] = probe - if selected is None and all(probe.get(k) for k in ("create_ok", "write_ok", "read_ok", "execute_ok")): + if selected is None and all( + probe.get(k) for k in ("create_ok", "write_ok", "read_ok", "execute_ok") + ): selected = candidate except Exception as exc: probes[key] = {"error": f"{type(exc).__name__}: {exc}"} @@ -191,6 +204,7 @@ def _resolve_trusted_dir(config): selected = BASE_DIR return selected, candidates, probes + def _sync_config_to_trusted(config_path, trusted_dir): trusted_cfg = trusted_dir / "launcher_config.json" sync_mode = "copy" @@ -214,6 +228,7 @@ def _sync_config_to_trusted(config_path, trusted_dir): sync_mode = "disabled" return trusted_cfg, sync_mode + def _set_runtime_base_dir(new_base_dir): global BASE_DIR, EMBEDDED_DIR, EMBEDDED_PYTHON, PORTABLEMC_BIN_DIR BASE_DIR = Path(new_base_dir) @@ -221,10 +236,12 @@ def _set_runtime_base_dir(new_base_dir): EMBEDDED_PYTHON = EMBEDDED_DIR / "python.exe" PORTABLEMC_BIN_DIR = BASE_DIR / "portablemc_bin" + def update_launcher_state(**updates): LAUNCHER_STATE.update(updates) _safe_json_dump(CONFIG_PATH, LAUNCHER_STATE) + def initialize_runtime_configuration(): global LAUNCHER_STATE, ACTIVE_TRUSTED_DIR loaded = _load_json_file(CONFIG_PATH) @@ -248,40 +265,42 @@ def initialize_runtime_configuration(): LAUNCHER_STATE["config_sync_mode"] = sync_mode _safe_json_dump(CONFIG_PATH, LAUNCHER_STATE) + # --- OS and architecture detection (for portablemc binary) --- SYSTEM = platform.system().lower() MACHINE = platform.machine().lower() ARCH_MAP = { - 'x86_64': 'x86_64', - 'amd64': 'x86_64', - 'i686': 'i686', - 'i386': 'i686', - 'aarch64': 'aarch64', - 'arm64': 'aarch64', - 'armv7l': 'arm-gnueabihf', - 'arm': 'arm-gnueabihf', + "x86_64": "x86_64", + "amd64": "x86_64", + "i686": "i686", + "i386": "i686", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "arm-gnueabihf", + "arm": "arm-gnueabihf", } OS_MAP = { - 'windows': 'windows', - 'linux': 'linux', - 'darwin': 'macos', + "windows": "windows", + "linux": "linux", + "darwin": "macos", } + def get_portablemc_url(): """Return the download URL for the native portablemc binary, or None.""" os_name = OS_MAP.get(SYSTEM) if not os_name: print(f"⚠️ Unsupported OS: {SYSTEM}") return None - arch = ARCH_MAP.get(MACHINE, 'x86_64') - if os_name == 'macos': - arch = 'aarch64' if arch == 'aarch64' else 'x86_64' - if os_name == 'linux' and arch not in ('arm-gnueabihf',): - arch += '-gnu' + arch = ARCH_MAP.get(MACHINE, "x86_64") + if os_name == "macos": + arch = "aarch64" if arch == "aarch64" else "x86_64" + if os_name == "linux" and arch not in ("arm-gnueabihf",): + arch += "-gnu" base = f"{PORTABLEMC_RELEASE_BASE}/" - if os_name == 'windows': + if os_name == "windows": ext = "zip" filename = f"portablemc-{PORTABLEMC_VERSION}-{os_name}-{arch}-msvc.{ext}" else: @@ -289,6 +308,7 @@ def get_portablemc_url(): filename = f"portablemc-{PORTABLEMC_VERSION}-{os_name}-{arch}.{ext}" return base + filename + # --- Data functions --- def prepare_user_data(): """Move static folder and game files to BASE_DIR if not already present.""" @@ -320,6 +340,7 @@ def prepare_user_data(): elif dst.exists(): print(f"ℹ️ {filename} already exists in %LOCALAPPDATA%\\PortableMC") + # --- Junction functions --- def is_junction(path): """Return True if path is a junction (reparse point).""" @@ -329,6 +350,7 @@ def is_junction(path): except OSError: return False + def create_junction(source, target): """ Create a junction from source to target. @@ -340,7 +362,9 @@ def create_junction(source, target): # Prevent self‑junction if source_path == target_path: - print(f"⚠️ Source and target are the same ({source_path}); skipping junction creation.") + print( + f"⚠️ Source and target are the same ({source_path}); skipping junction creation." + ) # Still ensure the target directory exists (as a regular folder) target_path.mkdir(parents=True, exist_ok=True) return False @@ -363,7 +387,9 @@ def create_junction(source, target): try: subprocess.run( ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - check=True, capture_output=True, text=True + check=True, + capture_output=True, + text=True, ) # nosec print(f"✅ Junction created: {target_path} -> {source_path}") links = LAUNCHER_STATE.get("managed_links", {}) @@ -371,10 +397,13 @@ def create_junction(source, target): update_launcher_state(managed_links=links) return True except subprocess.CalledProcessError as e: - print(f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}") + print( + f"⚠️ Could not create junction (falling back to regular directory): {e.stderr}" + ) target_path.mkdir(parents=True, exist_ok=True) return False + def ensure_junctions(): r"""Ensure mods/resourcepacks links target active runtime base directory.""" base_dir = ACTIVE_TRUSTED_DIR or BASE_DIR @@ -383,9 +412,12 @@ def ensure_junctions(): create_junction(ROOT_DIR / "mods", base_dir / "mods") create_junction(ROOT_DIR / "resourcepacks", base_dir / "resourcepacks") + def _run_harness_probe(name, cmd, cwd=None, timeout=8): try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd) # nosec + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd + ) # nosec return { "name": name, "command": cmd, @@ -400,6 +432,7 @@ def _run_harness_probe(name, cmd, cwd=None, timeout=8): "error": f"{type(exc).__name__}: {exc}", } + def run_restricted_env_test_harness(): """Run non-invasive restricted-environment compatibility probes.""" print("\n=== Restricted Environment Test Harness ===\n") @@ -414,14 +447,28 @@ def run_restricted_env_test_harness(): probes.append(_run_harness_probe("where_csc", ["where", "csc.exe"])) probes.append(_run_harness_probe("where_py", ["where", "py"])) probes.append(_run_harness_probe("where_python", ["where", "python"])) - probes.append(_run_harness_probe("msbuild_version", ["cmd", "/c", "MSBuild.exe -version"])) - probes.append(_run_harness_probe("powershell_version", ["powershell", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"])) + probes.append( + _run_harness_probe("msbuild_version", ["cmd", "/c", "MSBuild.exe -version"]) + ) + probes.append( + _run_harness_probe( + "powershell_version", + [ + "powershell", + "-NoProfile", + "-Command", + "$PSVersionTable.PSVersion.ToString()", + ], + ) + ) probes.append(_run_harness_probe("cscript_help", ["cscript", "//?"])) probes.append(_run_harness_probe("csc_help", ["csc", "/help"])) probes.append(_run_harness_probe("systeminfo", ["systeminfo"])) trusted_dir = ACTIVE_TRUSTED_DIR or BASE_DIR - probes.append(_run_harness_probe("trusted_dir_probe", ["cmd", "/c", "cd"], cwd=trusted_dir)) + probes.append( + _run_harness_probe("trusted_dir_probe", ["cmd", "/c", "cd"], cwd=trusted_dir) + ) lines = [] lines.append("=" * 80) @@ -434,12 +481,18 @@ def run_restricted_env_test_harness(): lines.append(f"config_path: {CONFIG_PATH}") lines.append("") lines.append("[effective_env]") - lines.append(f"PORTABLEMC_VERSION={os.environ.get('PORTABLEMC_VERSION', PORTABLEMC_VERSION)}") + lines.append( + f"PORTABLEMC_VERSION={os.environ.get('PORTABLEMC_VERSION', PORTABLEMC_VERSION)}" + ) lines.append(f"ALLOW_INSECURE_SSL={os.environ.get('ALLOW_INSECURE_SSL', '')}") lines.append(f"LAUNCHER_VERBOSE={os.environ.get('LAUNCHER_VERBOSE', '')}") lines.append("") lines.append("[trusted_dir_probes]") - lines.append(json.dumps(LAUNCHER_STATE.get("trusted_dir_probes", {}), indent=2, sort_keys=True)) + lines.append( + json.dumps( + LAUNCHER_STATE.get("trusted_dir_probes", {}), indent=2, sort_keys=True + ) + ) lines.append("") lines.append("[command_probes]") for probe in probes: @@ -465,13 +518,16 @@ def run_restricted_env_test_harness(): print(f"❌ Harness failed to write log: {exc}") return False + def remove_managed_link(target): target_path = Path(target) try: if not target_path.exists() and not target_path.is_symlink(): return True if target_path.is_symlink() or is_junction(target_path): - os.rmdir(str(target_path)) if target_path.is_dir() else target_path.unlink(missing_ok=True) + os.rmdir(str(target_path)) if target_path.is_dir() else target_path.unlink( + missing_ok=True + ) elif target_path.is_dir(): shutil.rmtree(target_path, ignore_errors=True) else: @@ -481,6 +537,7 @@ def remove_managed_link(target): print(f"⚠️ Failed to remove {target_path}: {exc}") return False + def run_debug_menu(): while True: print("\n" + "=" * 60) @@ -502,7 +559,10 @@ def run_debug_menu(): elif choice == "2": remove_managed_link(BASE_DIR / "resourcepacks") elif choice == "3": - if input("Confirm remove all managed links? (yes/no): ").strip().lower() == "yes": + if ( + input("Confirm remove all managed links? (yes/no): ").strip().lower() + == "yes" + ): links = list(LAUNCHER_STATE.get("managed_links", {}).keys()) for link in links: remove_managed_link(link) @@ -537,14 +597,18 @@ def run_debug_menu(): else: print("Invalid debug choice.") + # --- Download functions --- def get_ssl_context(): """Return an unverified SSL context if ALLOW_INSECURE_SSL is True, else None.""" if ALLOW_INSECURE_SSL: - print("⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true).") + print( + "⚠️ WARNING: SSL certificate verification is disabled (ALLOW_INSECURE_SSL=true)." + ) return ssl._create_unverified_context() return None + def download_file(url, dest_path): """Download a file with optional insecure fallback.""" try: @@ -557,7 +621,7 @@ def download_file(url, dest_path): try: context = get_ssl_context() with urllib.request.urlopen(url, context=context) as response: - with open(dest_path, 'wb') as f: + with open(dest_path, "wb") as f: f.write(response.read()) return True except Exception as e2: @@ -565,9 +629,12 @@ def download_file(url, dest_path): return False else: print("❌ Download failed and insecure SSL is disabled.") - print("⚠️ If you are in a restricted network, set ALLOW_INSECURE_SSL=true and try again.") + print( + "⚠️ If you are in a restricted network, set ALLOW_INSECURE_SSL=true and try again." + ) return False + # --- Helper functions --- def find_msbuild_candidates(): """Return a list of candidate MSBuild.exe paths sorted by priority (highest first).""" @@ -577,10 +644,21 @@ def find_msbuild_candidates(): vswhere = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" if os.path.isfile(vswhere): try: - result = subprocess.run([vswhere, "-latest", "-products", "*", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"], - capture_output=True, text=True, timeout=5) + result = subprocess.run( + [ + vswhere, + "-latest", + "-products", + "*", + "-find", + "MSBuild\\**\\Bin\\MSBuild.exe", + ], + capture_output=True, + text=True, + timeout=5, + ) if result.returncode == 0: - for line in result.stdout.strip().split('\n'): + for line in result.stdout.strip().split("\n"): if line and os.path.isfile(line): candidates.append((line, 100)) # highest priority except Exception: @@ -589,33 +667,76 @@ def find_msbuild_candidates(): # 2. Hard‑coded candidates with descending priorities hardcoded = [ # Visual Studio 2026 (v18.0) - (r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", 99), - (r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 99), - + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Professional\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\Community\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 99, + ), # Visual Studio 2022 (v17.0) - (r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", 90), - (r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 90), - + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), + ( + r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 90, + ), # Visual Studio 2019 (v16.0) - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", 80), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", 80), - + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + 80, + ), # Visual Studio 2017 (v15.0) - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", 70), - (r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", 70), - + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), + ( + r"C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe", + 70, + ), # Standalone Build Tools (v14.0, v12.0) (r"C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe", 60), (r"C:\Program Files (x86)\MSBuild\12.0\Bin\MSBuild.exe", 50), - # .NET Framework (64‑bit preferred, then 32‑bit) (r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe", 40), (r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe", 30), @@ -635,6 +756,7 @@ def find_msbuild_candidates(): sorted_candidates = sorted(unique.items(), key=lambda x: x[1], reverse=True) return [path for path, _ in sorted_candidates] + def ensure_embedded_python(): """Download and extract embedded Python to BASE_DIR if missing.""" BASE_DIR.mkdir(parents=True, exist_ok=True) @@ -654,6 +776,7 @@ def ensure_embedded_python(): print("✅ Embedded Python ready.") return True + def fix_pth_file(): """Enable site-packages in embedded Python's ._pth file.""" pth_files = list(EMBEDDED_DIR.glob("*._pth")) @@ -672,11 +795,16 @@ def fix_pth_file(): print("ℹ️ site-packages already enabled.") return True + def test_embedded_python(): """Test if the embedded Python executable can be run.""" try: - result = subprocess.run([str(EMBEDDED_PYTHON), "--version"], - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(EMBEDDED_PYTHON), "--version"], + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print(f"✅ Embedded Python runs: {result.stdout.strip()}") return True @@ -690,6 +818,7 @@ def test_embedded_python(): print(f"❌ Embedded Python test error: {e}") return False + def setup_embedded_python(): """Ensure embedded Python is downloaded, pth fixed, pip installed.""" if not ensure_embedded_python(): @@ -706,8 +835,10 @@ def setup_embedded_python(): env_check["PYTHONPATH"] = "" pip_check = subprocess.run( [str(EMBEDDED_PYTHON), "-m", "pip", "--version"], - env=env_check, capture_output=True, text=True - ) # nosec + env=env_check, + capture_output=True, + text=True, + ) # nosec if pip_check.returncode != 0: print("📦 pip not found, installing...") if not install_pip(EMBEDDED_PYTHON): @@ -716,14 +847,18 @@ def setup_embedded_python(): print(f"✅ pip already installed: {pip_check.stdout.strip()}") return True + def install_portablemc_via_embedded(): """Install portablemc in embedded Python and return method.""" print("📦 Installing portablemc (uv first, pip fallback)...") - if not install_packages_with_fallback(["portablemc"], python_exe=EMBEDDED_PYTHON, isolated=True): + if not install_packages_with_fallback( + ["portablemc"], python_exe=EMBEDDED_PYTHON, isolated=True + ): print("❌ Failed to install portablemc.") return None return test_portablemc(EMBEDDED_PYTHON) + def download_get_pip(): """Download get-pip.py into the embedded Python directory.""" pip_script = EMBEDDED_DIR / "get-pip.py" @@ -735,7 +870,7 @@ def download_get_pip(): try: context = get_ssl_context() # from earlier (secure or insecure) with urllib.request.urlopen(GET_PIP_URL, context=context) as response: - with open(pip_script, 'wb') as out_file: + with open(pip_script, "wb") as out_file: out_file.write(response.read()) print("✅ get-pip.py downloaded successfully.") except Exception as e: @@ -743,6 +878,7 @@ def download_get_pip(): return None return pip_script + def run_pip_command(args, isolated=True, python_exe=None): """Run a pip command with the given Python executable.""" if python_exe is None: @@ -752,7 +888,7 @@ def run_pip_command(args, isolated=True, python_exe=None): env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" cmd = [str(python_exe)] + args - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print(f"❌ Pip command failed: {' '.join(args)}") print(result.stderr) @@ -760,6 +896,7 @@ def run_pip_command(args, isolated=True, python_exe=None): print(result.stdout) return True + def run_uv_install(packages, python_exe=None, user=False): """Try package install using uv. Returns True on success.""" if python_exe is None: @@ -784,7 +921,10 @@ def run_uv_install(packages, python_exe=None, user=False): print(result.stdout.strip()) return True -def install_packages_with_fallback(packages, python_exe=None, isolated=True, user=False): + +def install_packages_with_fallback( + packages, python_exe=None, isolated=True, user=False +): """Install packages with uv first, then pip fallback.""" if python_exe is None: python_exe = EMBEDDED_PYTHON @@ -801,7 +941,10 @@ def install_packages_with_fallback(packages, python_exe=None, isolated=True, use update_launcher_state(installer_backend="pip") return ok -def run_portablemc_with_uvx_fallback(portablemc_args, env=None, cwd=None, python_exe=None): + +def run_portablemc_with_uvx_fallback( + portablemc_args, env=None, cwd=None, python_exe=None +): """Run portablemc with uvx first, python module fallback.""" uvx_cmd = ["uvx", "portablemc"] + portablemc_args print(f"🚀 Launching (uvx): {' '.join(uvx_cmd)}") @@ -820,6 +963,7 @@ def run_portablemc_with_uvx_fallback(portablemc_args, env=None, cwd=None, python py_code, py_output = run_command_live(py_cmd, env=env, cwd=cwd) return py_code, py_output, py_cmd + def install_pip(python_exe=None): """Install pip into the given Python environment.""" if python_exe is None: @@ -831,10 +975,13 @@ def install_pip(python_exe=None): env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" env["PYTHONPATH"] = "" - cmd = [str(python_exe), str(pip_script), - "--trusted-host=files.pythonhosted.org", - "--trusted-host=pypi.org"] - result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + cmd = [ + str(python_exe), + str(pip_script), + "--trusted-host=files.pythonhosted.org", + "--trusted-host=pypi.org", + ] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec if result.returncode != 0: print("❌ Failed to install pip.") print(result.stderr) @@ -842,20 +989,26 @@ def install_pip(python_exe=None): print("✅ pip installed.") return True + def install_base_packages(python_exe=None): """Install the base packages (flask, etc.) into the given Python.""" print("📦 Installing base packages...") if python_exe is None: python_exe = EMBEDDED_PYTHON - if not install_packages_with_fallback(["--upgrade", "pip"], isolated=True, python_exe=python_exe): + if not install_packages_with_fallback( + ["--upgrade", "pip"], isolated=True, python_exe=python_exe + ): print("⚠️ Pip upgrade failed, continuing anyway.") for pkg in BASE_PACKAGES: print(f" Installing {pkg}...") - if not install_packages_with_fallback([pkg], isolated=True, python_exe=python_exe): + if not install_packages_with_fallback( + [pkg], isolated=True, python_exe=python_exe + ): print(f"❌ Failed to install {pkg}.") return False return True + def get_certifi_path(python_exe=None): """Return the path to certifi's CA bundle, or None if certifi not installed.""" if python_exe is None: @@ -863,9 +1016,11 @@ def get_certifi_path(python_exe=None): try: result = subprocess.run( [str(python_exe), "-c", "import certifi; print(certifi.where())"], - capture_output=True, text=True, check=True, - env={"PYTHONNOUSERSITE": "1"} - ) # nosec + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, + ) # nosec path = result.stdout.strip() if path and Path(path).exists(): return path @@ -873,6 +1028,7 @@ def get_certifi_path(python_exe=None): pass return None + def download_portablemc_binary(): """Download and extract the native portablemc binary into BASE_DIR.""" url = get_portablemc_url() @@ -906,6 +1062,7 @@ def download_portablemc_binary(): print(f"✅ portablemc binary extracted to {PORTABLEMC_BIN_DIR}") return True + def test_portablemc(python_exe=None): """Check if portablemc is available (binary, uvx, or module).""" # Try binary first @@ -917,8 +1074,13 @@ def test_portablemc(python_exe=None): env = os.environ.copy() env["PATH"] = str(PORTABLEMC_BIN_DIR) + os.pathsep + env.get("PATH", "") try: - result = subprocess.run([str(binary_path), "--help"], env=env, - capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run( + [str(binary_path), "--help"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) # nosec if result.returncode == 0: print("✅ portablemc binary works.") return "binary" @@ -927,7 +1089,9 @@ def test_portablemc(python_exe=None): # Try uvx try: - result = subprocess.run(["uvx", "portablemc", "--help"], capture_output=True, text=True, timeout=8) # nosec + result = subprocess.run( + ["uvx", "portablemc", "--help"], capture_output=True, text=True, timeout=8 + ) # nosec if result.returncode == 0: print("✅ portablemc via uvx works.") return "uvx" @@ -942,7 +1106,7 @@ def test_portablemc(python_exe=None): env["PYTHONPATH"] = "" cmd = [str(python_exe), "-m", "portablemc", "--help"] try: - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec if result.returncode == 0: print("✅ portablemc module works.") return "module" @@ -950,6 +1114,7 @@ def test_portablemc(python_exe=None): print("⏱️ portablemc module check timed out, assuming not available.") return None + def ensure_portablemc(python_exe=None): """Make portablemc available – try binary, fallback to pip. Returns method string or None.""" method = test_portablemc(python_exe) @@ -961,12 +1126,15 @@ def ensure_portablemc(python_exe=None): return method print("⚠️ Binary download failed, falling back to pip.") print("📦 Installing portablemc (uv first, pip fallback)...") - if install_packages_with_fallback(["portablemc"], isolated=True, python_exe=python_exe): + if install_packages_with_fallback( + ["portablemc"], isolated=True, python_exe=python_exe + ): method = test_portablemc(python_exe) if method: return method return None + def collect_system_details(): """Collect diagnostic details. Individual probes can fail safely.""" include_sensitive = bool(LAUNCHER_STATE.get("include_sensitive_env_details", False)) @@ -994,10 +1162,18 @@ def collect_system_details(): else "" ) for k in [ - "USERNAME", "USERDOMAIN", "COMPUTERNAME", "PROCESSOR_ARCHITECTURE", - "LOCALAPPDATA", "APPDATA", "TEMP", "TMP", "COMSPEC", "PSModulePath" + "USERNAME", + "USERDOMAIN", + "COMPUTERNAME", + "PROCESSOR_ARCHITECTURE", + "LOCALAPPDATA", + "APPDATA", + "TEMP", + "TMP", + "COMSPEC", + "PSModulePath", ] - } + }, } probes = { @@ -1021,7 +1197,10 @@ def collect_system_details(): details["probes"][name] = {"error": f"{type(exc).__name__}: {exc}"} return details -def write_failure_dump(kind, message, command=None, output=None, returncode=None, exc=None, extra=None): + +def write_failure_dump( + kind, message, command=None, output=None, returncode=None, exc=None, extra=None +): """Write a resilient failure dump log and return its path (or None).""" try: logs_dir = PROJECT_LOGS_DIR @@ -1058,7 +1237,9 @@ def write_failure_dump(kind, message, command=None, output=None, returncode=None lines.append("[Stack Trace]") if exc is not None: - lines.append("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) + lines.append( + "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + ) else: lines.append("(no Python exception captured)") @@ -1077,6 +1258,7 @@ def write_failure_dump(kind, message, command=None, output=None, returncode=None print(f"⚠️ Failed to write diagnostic dump: {dump_exc}") return None + def run_command_live(cmd, env=None, cwd=None): """Run a command, stream output live, and capture it for diagnostics.""" output_lines = [] @@ -1102,11 +1284,12 @@ def run_command_live(cmd, env=None, cwd=None): pass raise + # --- System Python detection --- def get_system_python(): """Find a system Python 3.x executable, preferring 3.11 or higher. - Returns the path to a usable Python interpreter, or None. - Priority order: current interpreter, PATH, registry, common install paths. + Returns the path to a usable Python interpreter, or None. + Priority order: current interpreter, PATH, registry, common install paths. """ candidates = [] seen = set() @@ -1147,10 +1330,18 @@ def add_candidate(p): # 4. Common install locations for ver in PYTHON_VERSIONS: - num = ver.replace('.', '') - for base in (r"C:\Python{}", r"C:\Program Files\Python{}", r"C:\Program Files (x86)\Python{}"): + num = ver.replace(".", "") + for base in ( + r"C:\Python{}", + r"C:\Program Files\Python{}", + r"C:\Program Files (x86)\Python{}", + ): add_candidate(base.format(num) + "\\python.exe") - user_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / f"Python{num}" + user_dir = ( + Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + / "Programs" + / f"Python{num}" + ) add_candidate(user_dir / "python.exe") add_candidate(r"C:\Windows\Sysnative\python.exe") add_candidate(r"C:\Windows\System32\python.exe") @@ -1159,10 +1350,12 @@ def add_candidate(p): valid = [] for p in candidates: try: - result = subprocess.run([str(p), "--version"], capture_output=True, text=True, timeout=2) # nosec + result = subprocess.run( + [str(p), "--version"], capture_output=True, text=True, timeout=2 + ) # nosec combined = (result.stdout + result.stderr).strip() if result.returncode == 0 and "Python 3" in combined: - match = re.search(r'\d+(?:\.\d+)+', combined) + match = re.search(r"\d+(?:\.\d+)+", combined) if match: version_str = match.group() valid.append((version_str, p)) @@ -1172,11 +1365,12 @@ def add_candidate(p): if not valid: return None - valid.sort(key=lambda x: tuple(map(int, x[0].split('.'))), reverse=True) + valid.sort(key=lambda x: tuple(map(int, x[0].split("."))), reverse=True) best = valid[0][1] print(f"Selected system Python: {best}") return best + # --- Launcher functions --- def launch_launcher(method, python_exe=None, extra_env=None): launcher_script = PORTABLEMC_PY @@ -1223,6 +1417,7 @@ def launch_launcher(method, python_exe=None, extra_env=None): print("⏹️ Interrupted by user.") return True + def run_web_launcher(): """Attempt to use embedded Python; if blocked, fall back to system Python.""" print("\n=== Bootstrapping environment for web launcher ===\n") @@ -1260,10 +1455,14 @@ def run_web_launcher(): print("📦 Installing required packages with system Python...") for pkg in BASE_PACKAGES + ["portablemc"]: print(f" Installing {pkg}...") - if not install_packages_with_fallback([pkg], python_exe=sys_python, isolated=False, user=True): + if not install_packages_with_fallback( + [pkg], python_exe=sys_python, isolated=False, user=True + ): print(f"❌ Failed to install {pkg}.") return False - print(f" ✅ {pkg} installed via {LAUNCHER_STATE.get('installer_backend', 'pip')}.") + print( + f" ✅ {pkg} installed via {LAUNCHER_STATE.get('installer_backend', 'pip')}." + ) # Test portablemc with system Python method = test_portablemc(sys_python) @@ -1276,10 +1475,11 @@ def run_web_launcher(): extra_env = { "PYTHONUSERBASE": str(launcher_python_dir), "PYTHONNOUSERSITE": "0", - "PATH": os.environ["PATH"] # keep the original PATH + "PATH": os.environ["PATH"], # keep the original PATH } return launch_launcher(method, sys_python, extra_env) + def run_msbuild_launcher(): print("\n=== Launching via MSBuild ===\n") prepare_user_data() @@ -1307,7 +1507,7 @@ def run_msbuild_launcher(): f"/p:JvmOpts={DEFAULT_JVM_OPTS}", "/p:UsePowerShell=true", "/p:UseCsc=false", - "/p:UseVbs=false" + "/p:UseVbs=false", ] print(f"Executing: {' '.join(cmd)}") try: @@ -1322,11 +1522,13 @@ def run_msbuild_launcher(): command=cmd, output=output, returncode=result_code, - extra={"candidate": msbuild_path} + extra={"candidate": msbuild_path}, ) if dump: print(f"📝 Failure dump written: {dump}") - print(f"⚠️ MSBuild at {msbuild_path} exited with code {result_code}. Trying next candidate.") + print( + f"⚠️ MSBuild at {msbuild_path} exited with code {result_code}. Trying next candidate." + ) except KeyboardInterrupt: raise except Exception as e: @@ -1335,34 +1537,47 @@ def run_msbuild_launcher(): message=f"Exception while running MSBuild candidate: {msbuild_path}", command=cmd, exc=e, - extra={"candidate": msbuild_path} + extra={"candidate": msbuild_path}, ) if dump: print(f"📝 Failure dump written: {dump}") - print(f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate.") + print( + f"⚠️ Failed to execute MSBuild at {msbuild_path}: {e}. Trying next candidate." + ) print("❌ All MSBuild candidates failed.") return False + def run_cli_launcher(): """Launch portablemc in CLI mode using the embedded Python (or system Python fallback).""" print("\n=== Bootstrapping environment for CLI launcher ===\n") prepare_user_data() - failure_context = {"mode": "cli", "server": DEFAULT_SERVER_IP, "username": DEFAULT_USERNAME} + failure_context = { + "mode": "cli", + "server": DEFAULT_SERVER_IP, + "username": DEFAULT_USERNAME, + } # Try embedded Python first if setup_embedded_python(): # Install portablemc (and certifi for SSL) into embedded Python method = install_portablemc_via_embedded() if not method: - dump = write_failure_dump("cli", "Embedded Python portablemc install failed.", extra=failure_context) + dump = write_failure_dump( + "cli", + "Embedded Python portablemc install failed.", + extra=failure_context, + ) if dump: print(f"📝 Failure dump written: {dump}") return False # Install certifi to get CA bundle print("📦 Installing certifi for SSL support...") - if not install_packages_with_fallback(["certifi"], isolated=True, python_exe=EMBEDDED_PYTHON): + if not install_packages_with_fallback( + ["certifi"], isolated=True, python_exe=EMBEDDED_PYTHON + ): print("⚠️ Failed to install certifi; SSL errors may occur.") else: print("✅ certifi installed.") @@ -1376,12 +1591,16 @@ def run_cli_launcher(): # Build CLI arguments (portablemc syntax) jvm_arg_tokens = [arg for arg in DEFAULT_JVM_OPTS.split() if arg.strip()] portablemc_args = [ - "--main-dir", ".", - "--output", "human-color", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, + "--server", + DEFAULT_SERVER_IP, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] for token in reversed(jvm_arg_tokens): portablemc_args.insert(7, f"--jvm-arg={token}") @@ -1406,7 +1625,7 @@ def run_cli_launcher(): command=used_cmd, output=output, returncode=result_code, - extra=failure_context + extra=failure_context, ) if dump: print(f"📝 Failure dump written: {dump}") @@ -1431,7 +1650,9 @@ def run_cli_launcher(): print("\n⚠️ Embedded Python not usable. Trying system Python...") sys_python = get_system_python() if not sys_python: - dump = write_failure_dump("cli", "System Python not found for CLI fallback.", extra=failure_context) + dump = write_failure_dump( + "cli", "System Python not found for CLI fallback.", extra=failure_context + ) if dump: print(f"📝 Failure dump written: {dump}") print("❌ No system Python found. Cannot proceed.") @@ -1448,12 +1669,22 @@ def run_cli_launcher(): # Install portablemc and certifi with system Python print("📦 Installing portablemc with system Python...") - if not install_packages_with_fallback(["portablemc", "certifi"], python_exe=sys_python, isolated=False, user=True): + if not install_packages_with_fallback( + ["portablemc", "certifi"], python_exe=sys_python, isolated=False, user=True + ): dump = write_failure_dump( "cli", "package install failed in system Python fallback (uv and pip).", - command=[str(sys_python), "-m", "pip/uv", "install", "--user", "portablemc", "certifi"], - extra=failure_context + command=[ + str(sys_python), + "-m", + "pip/uv", + "install", + "--user", + "portablemc", + "certifi", + ], + extra=failure_context, ) if dump: print(f"📝 Failure dump written: {dump}") @@ -1462,7 +1693,11 @@ def run_cli_launcher(): method = test_portablemc(sys_python) if not method: - dump = write_failure_dump("cli", "portablemc unavailable after system Python install.", extra=failure_context) + dump = write_failure_dump( + "cli", + "portablemc unavailable after system Python install.", + extra=failure_context, + ) if dump: print(f"📝 Failure dump written: {dump}") print("❌ portablemc not available after installation.") @@ -1474,12 +1709,16 @@ def run_cli_launcher(): jvm_arg_tokens = [arg for arg in DEFAULT_JVM_OPTS.split() if arg.strip()] portablemc_args = [ - "--main-dir", ".", - "--output", "human-color", + "--main-dir", + ".", + "--output", + "human-color", "start", - "--server", DEFAULT_SERVER_IP, + "--server", + DEFAULT_SERVER_IP, "fabric:", - "-u", DEFAULT_USERNAME + "-u", + DEFAULT_USERNAME, ] for token in reversed(jvm_arg_tokens): portablemc_args.insert(7, f"--jvm-arg={token}") @@ -1500,7 +1739,7 @@ def run_cli_launcher(): command=used_cmd, output=output, returncode=result_code, - extra=failure_context + extra=failure_context, ) if dump: print(f"📝 Failure dump written: {dump}") @@ -1521,6 +1760,7 @@ def run_cli_launcher(): print(f"❌ CLI launcher exited with error: {e}") return False + def main(): initialize_runtime_configuration() # Display menu @@ -1552,9 +1792,12 @@ def main(): print("Invalid choice. Exiting.") sys.exit(1) - update_launcher_state(success=bool(success), last_error="" if success else "launcher returned failure") + update_launcher_state( + success=bool(success), last_error="" if success else "launcher returned failure" + ) sys.exit(0 if success else 1) + if __name__ == "__main__": try: main() diff --git a/scripts/portablemc.py b/scripts/portablemc.py index 6ef7c60..b4956e4 100644 --- a/scripts/portablemc.py +++ b/scripts/portablemc.py @@ -32,12 +32,14 @@ app = Flask(__name__, static_folder=str(BASE_DIR / "static")) socketio = SocketIO(app, cors_allowed_origins="*") + def escape_html(s): """Escape only &, <, > for safe innerHTML – leaves quotes and apostrophes untouched.""" - return s.replace('&', '&').replace('<', '<').replace('>', '>') + return s.replace("&", "&").replace("<", "<").replace(">", ">") + # --- CONFIGURATION --- -VALID_USERNAME_REGEX = re.compile(r'^[a-zA-Z0-9_]{3,16}$') +VALID_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{3,16}$") FORBIDDEN_LIST = ["CubeUniform840", "Admin", "Owner"] PASS_KEY = "1234" SERVER_IP = "77.103.184.72" @@ -56,6 +58,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -68,6 +71,7 @@ def escape_html(s): # Fallbacks when certain libraries are restricted/corrupted/disabled try: from ansi2html import Ansi2HTMLConverter + ansi_converter = Ansi2HTMLConverter(dark_bg=True, inline=True) use_server_conversion = True except ImportError: @@ -923,34 +927,38 @@ def escape_html(s): """ + # --- ROUTES --- -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): global connected_clients with clients_lock: connected_clients += 1 client_id = request.sid - print(f'Client connected: {client_id} (Total: {connected_clients})') - emit('status', {'core': 'online', 'minecraft': 'checking'}) + print(f"Client connected: {client_id} (Total: {connected_clients})") + emit("status", {"core": "online", "minecraft": "checking"}) + -@socketio.on('disconnect') +@socketio.on("disconnect") def handle_disconnect(): global connected_clients with clients_lock: connected_clients -= 1 client_id = request.sid - print(f'Client disconnected: {client_id} (Total: {connected_clients})') + print(f"Client disconnected: {client_id} (Total: {connected_clients})") -@socketio.on('ping_minecraft') + +@socketio.on("ping_minecraft") def handle_ping_minecraft(): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1.5) s.connect((SERVER_IP, 25565)) s.close() - emit('minecraft_status', {'online': True}) + emit("minecraft_status", {"online": True}) except Exception: - emit('minecraft_status', {'online': False}) + emit("minecraft_status", {"online": False}) + @app.route("/ping") def ping(): @@ -963,6 +971,7 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -972,15 +981,17 @@ def is_portablemc_available(): return importlib.util.find_spec("portablemc") is not None if not is_portablemc_available(): + def error_gen(): lines = [ "\x1b[91m[!] PORTABLEMC NOT FOUND\x1b[0m", - "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m" + "\x1b[93mPlease install it via 'pip install portablemc'.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- GET USER INPUT --- @@ -989,31 +1000,44 @@ def error_gen(): # --- VALIDATIONS (JSON wrapped) --- if not user: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"}) + payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] USERNAME REQUIRED\x1b[0m"} + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if not VALID_USERNAME_REGEX.match(user): + def error_gen(): lines = [ "\x1b[91m[!] INVALID USERNAME\x1b[0m", - "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m" + "\x1b[93mUsername must be 3-16 characters and only letters, numbers, or underscore.\x1b[0m", ] for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") user_lower = user.lower() forbidden_lower = [name.lower() for name in FORBIDDEN_LIST] if user_lower in forbidden_lower and password != PASS_KEY: + def error_gen(): - payload = json.dumps({'type': 'ansi', 'content': "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[!] ACCESS DENIED – INVALID SECURE_KEY\x1b[0m", + } + ) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") # --- PREVENT MULTIPLE LAUNCHES (thread-safe) --- @@ -1022,13 +1046,15 @@ def error_gen(): lines = [ "\x1b[91m[!] CORE BUSY\x1b[0m", "\x1b[93mAnother Minecraft instance is already running.\x1b[0m", - "\x1b[90mPlease close the game before launching again.\x1b[0m" + "\x1b[90mPlease close the game before launching again.\x1b[0m", ] + def error_gen(): for line in lines: - payload = json.dumps({'type': 'ansi', 'content': line}) + payload = json.dumps({"type": "ansi", "content": line}) yield f"data: {payload}\n\n" yield "data: CLOSE\n\n" + return Response(error_gen(), mimetype="text/event-stream") if user in active_processes: del active_processes[user] @@ -1043,7 +1069,9 @@ def error_gen(): launcher_cmd = [sys.executable, "-m", "portablemc"] # Determine base directory for portablemc data (same as the launcher uses) - local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local")) + local_appdata = os.environ.get( + "LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local") + ) base_dir = os.path.join(local_appdata, "PortableMC") # Global arguments (before 'start') @@ -1099,13 +1127,15 @@ def generate(): try: with processes_lock: if user in active_processes and active_processes[user].poll() is None: - busy_payload = json.dumps({'type': 'ansi', 'content': '\x1b[91m[!] CORE BUSY\x1b[0m'}) + busy_payload = json.dumps( + {"type": "ansi", "content": "\x1b[91m[!] CORE BUSY\x1b[0m"} + ) yield f"data: {busy_payload}\n\n" yield "data: CLOSE\n\n" return startupinfo = None - if os.name == 'nt': + if os.name == "nt": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE @@ -1121,7 +1151,7 @@ def generate(): errors="replace", bufsize=1, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - startupinfo=startupinfo + startupinfo=startupinfo, ) active_processes[user] = process logging.info(f"Started process for {user} with PID {process.pid}") @@ -1133,7 +1163,12 @@ def generate(): while True: if closed_event.is_set(): - disc_msg = json.dumps({'type': 'ansi', 'content': "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m"}) + disc_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[91m[SYSTEM] CONNECTION CLOSED\x1b[0m", + } + ) try: yield f"data: {disc_msg}\n\n" except (BrokenPipeError, OSError, GeneratorExit): @@ -1146,7 +1181,7 @@ def generate(): break continue - raw_line = line.rstrip('\n') + raw_line = line.rstrip("\n") now = time.perf_counter() if not ok_reached and "[ OK ]" in raw_line: @@ -1157,18 +1192,23 @@ def generate(): if use_server_conversion and ansi_converter: try: safe_line = escape_html(raw_line) - html_line = ansi_converter.convert(safe_line, full=False).strip() - payload = json.dumps({'type': 'html', 'content': html_line}) + html_line = ansi_converter.convert( + safe_line, full=False + ).strip() + payload = json.dumps({"type": "html", "content": html_line}) except Exception as e: logging.error(f"Conversion failed: {e}, sending raw ANSI") - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) else: - payload = json.dumps({'type': 'ansi', 'content': raw_line}) + payload = json.dumps({"type": "ansi", "content": raw_line}) try: if progress_match and not ok_reached: current_file = progress_match.group(1) - if current_file != last_progress and (now - last_send_time) > update_interval: + if ( + current_file != last_progress + and (now - last_send_time) > update_interval + ): yield f"data: {payload}\n\n" last_progress = current_file last_send_time = now @@ -1182,14 +1222,21 @@ def generate(): except FileNotFoundError as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m"}) + payload = json.dumps( + { + "type": "ansi", + "content": f"\x1b[91m[SYSTEM] Launcher not found: {str(e)}\x1b[0m", + } + ) yield f"data: {payload}\n\n" except Exception: pass except Exception as e: if not closed_event.is_set(): try: - payload = json.dumps({'type': 'ansi', 'content': f"[SYSTEM ERROR] {str(e)}"}) + payload = json.dumps( + {"type": "ansi", "content": f"[SYSTEM ERROR] {str(e)}"} + ) yield f"data: {payload}\n\n" except Exception: pass @@ -1204,8 +1251,18 @@ def generate(): if not got_generator_exit: try: - ended_msg = json.dumps({'type': 'ansi', 'content': "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m"}) - tip_msg = json.dumps({'type': 'ansi', 'content': "\x1b[34m[TIP] Click the console to return to login.\x1b[0m"}) + ended_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[90m[SYSTEM] SESSION ENDED\x1b[0m", + } + ) + tip_msg = json.dumps( + { + "type": "ansi", + "content": "\x1b[34m[TIP] Click the console to return to login.\x1b[0m", + } + ) yield f"data: {ended_msg}\n\n" yield f"data: {tip_msg}\n\n" yield "data: CLOSE\n\n" @@ -1218,10 +1275,12 @@ def generate(): response.call_on_close(closed_event.set) return response + @app.route("/") def home(): return render_template_string(HTML_TEMPLATE, forbidden_list=FORBIDDEN_LIST) + def kill_process_tree(proc): """Kill a process and all its children. Falls back to simple kill if psutil missing.""" if proc.poll() is not None: @@ -1248,6 +1307,7 @@ def kill_process_tree(proc): proc.kill() logging.info(f"Terminated process PID {proc.pid} (psutil unavailable)") + def graceful_shutdown(sig, frame): logging.info("SHUTTING DOWN CORE...") with processes_lock: @@ -1255,10 +1315,11 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__": try: socketio.run(app, port=5000, debug=False, allow_unsafe_werkzeug=True) except KeyboardInterrupt: - graceful_shutdown(None, None) \ No newline at end of file + graceful_shutdown(None, None)