diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f34e4b8..aa7de62 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 @@ -38,9 +38,48 @@ 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.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 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 + + 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 (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.' + }) diff --git a/.gitignore b/.gitignore index 6b29363..86e6db1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ PortableMCLoader.exe *.tmp *.log -# User data and game files +# User data (optional, keep if you want to exclude user‑specific files) mods/ resourcepacks/ servers.dat 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. diff --git a/README.md b/README.md index a88fd94..b151ef4 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,101 @@ -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](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) -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 +> [!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) +- **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 +- **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. + +```text +Minecraft-Portable-Web/ +├── main.py # Entry point – menu & bootstrapping +├── scripts/ # Launcher helper scripts +│ ├── Launcher.targets # MSBuild task +│ ├── PortableMCLoader.cs # C# loader (compiled if needed) +│ ├── Launcher.vbs # VBScript fallback +│ └── Launcher.ps1 # PowerShell fallback +| └── portablemc.py # Web server (Flask + SocketIO) +├── static/ # Web UI Assets +├── 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 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. +- (Optional) VBScript support – used as a final fallback. + +## TL;DR +* Test on a **Restricted Environment** by **Group Policy** +* Port all code to **Rust** + +## 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) +* `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: + +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 a method is blocked, it tries the next. All launchers set __COMPAT_LAYER=RUNASINVOKER to avoid UAC prompts. +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. + +## 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. + +## 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! -*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 +* [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. diff --git a/main.py b/main.py index 20b4c84..a3ddd86 100644 --- a/main.py +++ b/main.py @@ -1 +1,1806 @@ -# Dummy file +#!/usr/bin/env python3 +r""" +Cross‑platform bootstrap script for embedded Python 3.14 (Windows only). +All files are stored in %LOCALAPPDATA%\PortableMC. +Run with any Python (e.g., system 3.11) to set up and launch the launcher. +""" + +import os +import sys +import subprocess +import stat +import urllib.request +import zipfile +import tarfile +import shutil +import platform +import ssl +import re +import winreg +import datetime +import traceback +import json +from pathlib import Path + +# --- Windows-only guard --- +if platform.system().lower() != "windows": + print("❌ This bootstrap script currently only supports Windows.") + sys.exit(1) + +# --- Configuration --- +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" +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"] +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 +) + +# Default game settings +DEFAULT_USERNAME = "CubeUniform840" +DEFAULT_SERVER_IP = "77.103.184.72" +DEFAULT_JVM_OPTS = "-Xmx3G -Xms3G" + +# Paths +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" +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": [], + "include_sensitive_env_details": False, +} + + +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", +} + +OS_MAP = { + "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 = f"{PORTABLEMC_RELEASE_BASE}/" + if os_name == "windows": + ext = "zip" + filename = f"portablemc-{PORTABLEMC_VERSION}-{os_name}-{arch}-msvc.{ext}" + else: + ext = "tar.gz" + 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.""" + 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 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. + 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() + + # Prevent self‑junction + if source_path == target_path: + 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 + + # Ensure source exists + source_path.mkdir(parents=True, exist_ok=True) + + # Remove existing target if it exists + if target_path.exists(): + 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(): + 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, + ) # 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}" + ) + 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 + 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"])) + 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) + ) + + 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("[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("") + 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) + 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("7) Advanced restricted-env test harness") + print("8) Toggle sensitive env fields in dumps") + 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 == "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)." + ) + return ssl._create_unverified_context() + return None + + +def download_file(url, dest_path): + """Download a file with optional insecure fallback.""" + try: + 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(url, context=context) as response: + with open(dest_path, "wb") as f: + f.write(response.read()) + return True + except Exception as e2: + print(f"❌ Download failed even with unverified SSL: {e2}") + 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).""" + 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) + 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) + 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.") + return False + pth = pth_files[0] + with open(pth, "r") as f: + content = f.read() + if "import site" in content and "#import site" in content: + content = content.replace("#import site", "import site") + with open(pth, "w") as f: + f.write(content) + print("✅ Enabled site-packages in ._pth file.") + else: + 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 + if result.returncode == 0: + print(f"✅ Embedded Python runs: {result.stdout.strip()}") + return True + else: + print(f"❌ Embedded Python test failed: {result.stderr}") + return False + except OSError as e: + 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 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 (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" + if pip_script.exists(): + print("✅ get-pip.py already present.") + return pip_script + + print("📥 Downloading get-pip.py (ignoring SSL cert for this request)...") + 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: + out_file.write(response.read()) + print("✅ get-pip.py downloaded successfully.") + except Exception as e: + print(f"❌ Failed to download get-pip.py: {e}") + 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: + python_exe = EMBEDDED_PYTHON + env = os.environ.copy() + if isolated: + env["PYTHONNOUSERSITE"] = "1" + env["PYTHONPATH"] = "" + cmd = [str(python_exe)] + args + 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) + return False + 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.stdout: + print(result.stdout.strip()) + 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 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.""" + if python_exe is None: + python_exe = EMBEDDED_PYTHON + 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", + ] + result = subprocess.run(cmd, env=env, capture_output=True, text=True) # nosec + if result.returncode != 0: + print("❌ Failed to install pip.") + print(result.stderr) + return False + 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 + ): + 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 + ): + 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: + python_exe = EMBEDDED_PYTHON + try: + result = subprocess.run( + [str(python_exe), "-c", "import certifi; print(certifi.where())"], + capture_output=True, + text=True, + check=True, + env={"PYTHONNOUSERSITE": "1"}, + ) # nosec + path = result.stdout.strip() + if path and Path(path).exists(): + return path + except Exception: + pass + 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}...") + BASE_DIR.mkdir(parents=True, exist_ok=True) + archive_path = BASE_DIR / "portablemc_download" + if not download_file(url, archive_path): + return False + + PORTABLEMC_BIN_DIR.mkdir(exist_ok=True) + try: + if url.endswith(".zip"): + with zipfile.ZipFile(archive_path, "r") as zip_ref: + zip_ref.extractall(PORTABLEMC_BIN_DIR) + else: + with tarfile.open(archive_path, "r:gz") as tar_ref: + tar_ref.extractall(PORTABLEMC_BIN_DIR) + except Exception as e: + print(f"❌ Failed to extract archive: {e}") + return False + finally: + archive_path.unlink(missing_ok=True) + # Flatten subdirectories + for item in PORTABLEMC_BIN_DIR.iterdir(): + if item.is_dir(): + for sub in item.iterdir(): + sub.rename(PORTABLEMC_BIN_DIR / sub.name) + item.rmdir() + 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 + exe_name = "portablemc.exe" if SYSTEM == "windows" else "portablemc" + binary_path = PORTABLEMC_BIN_DIR / exe_name + if binary_path.exists(): + if SYSTEM != "windows": + binary_path.chmod(binary_path.stat().st_mode | 0o111) + 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 + 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 + env = os.environ.copy() + env["PYTHONNOUSERSITE"] = "1" + env["PYTHONPATH"] = "" + cmd = [str(python_exe), "-m", "portablemc", "--help"] + try: + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=5) # nosec + if result.returncode == 0: + print("✅ portablemc module works.") + return "module" + except subprocess.TimeoutExpired: + 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) + if method: + return method + if download_portablemc_binary(): + method = test_portablemc(python_exe) + if method: + 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 + ): + 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(), + "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, "") + if include_sensitive or k not in sensitive_keys + else "" + ) + 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, + ) + 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. + """ + candidates = [] + seen = set() + + def add_candidate(p): + p = Path(p).resolve() + 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 embedded) + current = Path(sys.executable) + if current != EMBEDDED_PYTHON and not str(current).startswith(str(BASE_DIR)): + add_candidate(current) + + # 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 + 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") + add_candidate(Path(install_path) / "python.exe") + i += 1 + except OSError: + break + winreg.CloseKey(key) + + # 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{}", + ): + add_candidate(base.format(num) + "\\python.exe") + 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") + + # Verify candidates + 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: + 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 + + if not valid: + return None + + 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 + 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() + 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, 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["PYTHONPATH"] = "" + env["PORTABLEMC_METHOD"] = method + + cert_path = get_certifi_path(python_exe) + if cert_path: + env["SSL_CERT_FILE"] = cert_path + env["REQUESTS_CA_BUNDLE"] = cert_path + + cmd = [str(python_exe), str(launcher_script)] + print(f"🚀 Launching: {' '.join(cmd)}") + try: + subprocess.run(cmd, env=env, check=True) # nosec + except subprocess.CalledProcessError as e: + print(f"❌ Launcher exited with error: {e}") + return False + except KeyboardInterrupt: + 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 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...") + 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}...") + 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')}." + ) + + # Test portablemc with system Python + method = test_portablemc(sys_python) + if not method: + print("❌ portablemc not available even after installation.") + return False + + print("✅ Setup complete. Launching portablemc.py with system Python...") + # Launch portablemc.py with system Python, using the same environment + 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") + prepare_user_data() + + candidates = find_msbuild_candidates() + if not candidates: + print("❌ No MSBuild.exe found on the system.") + return False + + for msbuild_path in candidates: + 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" + + cmd = [ + msbuild_path, + str(TARGETS_FILE), + 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: + result_code, output = run_command_live(cmd, env=env) + if result_code == 0: + print("✅ MSBuild succeeded.") + return True + else: + 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: + 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 install_packages_with_fallback( + ["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 (portablemc syntax) + jvm_arg_tokens = [arg for arg in DEFAULT_JVM_OPTS.split() if arg.strip()] + portablemc_args = [ + "--main-dir", + ".", + "--output", + "human-color", + "start", + "--server", + DEFAULT_SERVER_IP, + "fabric:", + "-u", + DEFAULT_USERNAME, + ] + for token in reversed(jvm_arg_tokens): + portablemc_args.insert(7, f"--jvm-arg={token}") + + 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 + + try: + 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=used_cmd, + output=output, + returncode=result_code, + extra=failure_context, + ) + if dump: + print(f"📝 Failure dump written: {dump}") + return False + return True + except KeyboardInterrupt: + raise + except Exception as e: + 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}") + return False + + # 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 + + 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 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 + ): + 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 + + cert_path = get_certifi_path(sys_python) # certifi should now be installed + + ensure_junctions() + + jvm_arg_tokens = [arg for arg in DEFAULT_JVM_OPTS.split() if arg.strip()] + portablemc_args = [ + "--main-dir", + ".", + "--output", + "human-color", + "start", + "--server", + DEFAULT_SERVER_IP, + "fabric:", + "-u", + DEFAULT_USERNAME, + ] + for token in reversed(jvm_arg_tokens): + 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 + + try: + 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=used_cmd, + output=output, + returncode=result_code, + extra=failure_context, + ) + if dump: + print(f"📝 Failure dump written: {dump}") + return False + return True + except KeyboardInterrupt: + raise + except Exception as e: + 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}") + return False + + +def main(): + initialize_runtime_configuration() + # 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": + 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) + else: + 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__": + try: + main() + except KeyboardInterrupt: + print("\n[HALT] Keyboard interrupted.") + sys.exit(130) 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 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..d4f5ccd --- /dev/null +++ b/scripts/Launcher.ps1 @@ -0,0 +1,141 @@ +param( + [string]$Username, + [string]$ServerIp, + [string]$JvmOpts +) + +$ErrorActionPreference = "Stop" + +# 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] +} + +$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 +} + +# 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" + +# --- 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 optional SSL fallback) --- +if (-not (Test-Path $exePath)) { + Write-Host "portablemc.exe not found. Downloading to $binDir..." + $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", "on") + + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing + } catch { + Write-Host "First download attempt failed: $_" + 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) + Remove-Item $zipPath + + # Flatten subdirectories + Get-ChildItem $binDir -Directory | ForEach-Object { + Get-ChildItem $_.FullName -File | Move-Item -Destination $binDir -Force + Remove-Item $_.FullName -Recurse + } +} + +# --- Process JVM options and launch --- +$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 "Working directory: $baseDir" +$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..." + } + } + + $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)" + } + } + + Write-Host "ERROR: All launch methods failed (native and Python fallback)." + exit 1 +} finally { + Pop-Location +} \ No newline at end of file diff --git a/scripts/Launcher.targets b/scripts/Launcher.targets new file mode 100644 index 0000000..ab4f174 --- /dev/null +++ b/scripts/Launcher.targets @@ -0,0 +1,85 @@ + + + + + true + false + false + + $(TEMP) + $(TempDir)\PortableMCLoader.cs + $(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 + + + + + + + + + + + + + + + + true + + + + + + + + + true + + + + + + + + + true + + + + + + \ No newline at end of file diff --git a/scripts/Launcher.vbs b/scripts/Launcher.vbs new file mode 100644 index 0000000..bc974b8 --- /dev/null +++ b/scripts/Launcher.vbs @@ -0,0 +1,170 @@ +' Launcher.vbs +' Usage: cscript Launcher.vbs +' Stores all files in %LOCALAPPDATA%\PortableMC + +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") + +' --- Download function with SSL fallback --- +Function DownloadFile(url, destPath) + ' 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" Or _ + LCase(shell.Environment("PROCESS")("ALLOW_INSECURE_SSL")) = "on" + + 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 + + ' 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 + DownloadFile = False + End If + Else + 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..." + 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." + 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 + +' --- 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..ba0ba29 --- /dev/null +++ b/scripts/PortableMCLoader.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Diagnostics; + +class Program +{ + static void Main(string[] args) + { + // Force TLS 1.2 + 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"); + + if (!File.Exists(exePath)) + { + Console.WriteLine("portablemc.exe not found. Downloading..."); + 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)) + { + Environment.Exit(1); + } + + Console.WriteLine("Extracting..."); + try + { + ZipFile.ExtractToDirectory(zipPath, binDir); + } + catch (Exception ex) + { + Console.WriteLine(string.Format("Extraction failed: {0}", ex.Message)); + Environment.Exit(1); + } + finally + { + File.Delete(zipPath); + } + + FlattenDirectory(binDir); + Console.WriteLine("Extraction complete."); + } + + 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 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" || envVar.ToLower() == "on"); + + try + { + 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)); + if (allowInsecure) + { + 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; + } + } + else + { + Console.WriteLine("SSL verification failed and insecure SSL is disabled."); + return false; + } + } + } + + 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/portablemc.py b/scripts/portablemc.py similarity index 89% rename from portablemc.py rename to scripts/portablemc.py index 035b83c..b4956e4 100644 --- a/portablemc.py +++ b/scripts/portablemc.py @@ -10,20 +10,36 @@ import shutil import threading import logging -import traceback import os import json import importlib.util -app = Flask(__name__) +# --- Guard: must be launched through main.py --- +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.") + 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" +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('>', '>') + 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" @@ -42,6 +58,7 @@ def escape_html(s): # Optional psutil for process tree killing try: import psutil + PSUTIL_AVAILABLE = True except ImportError: psutil = None @@ -54,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: @@ -909,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(): @@ -949,6 +971,7 @@ def ping(): except Exception: return jsonify(online=False), 200 + @app.route("/stream") def stream(): # --- PORTABLEMC AVAILABILITY CHECK --- @@ -958,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 --- @@ -975,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) --- @@ -1008,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] @@ -1028,8 +1068,14 @@ 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"]) @@ -1081,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 @@ -1103,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}") @@ -1115,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): @@ -1128,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: @@ -1139,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 @@ -1164,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 @@ -1186,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" @@ -1200,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: @@ -1230,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: @@ -1237,6 +1315,7 @@ def graceful_shutdown(sig, frame): kill_process_tree(proc) sys.exit(0) + signal.signal(signal.SIGINT, graceful_shutdown) if __name__ == "__main__":