From b6d179442b884f46c271734a56077f7d2c696ca0 Mon Sep 17 00:00:00 2001 From: Stoppedwumm <129097720+Stoppedwumm@users.noreply.github.com> Date: Sat, 3 May 2025 13:56:31 +0000 Subject: [PATCH 01/19] =?UTF-8?q?F=C3=BCge=20die=20Benutzeroberfl=C3=A4che?= =?UTF-8?q?=20f=C3=BCr=20die=20Konsole=20und=20das=20Mod-Dashboard=20hinzu?= =?UTF-8?q?,=20implementiere=20die=20Nachrichten-=20und=20Fehlerverarbeitu?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/chrome/console.html | 22 ++++++++++ ui/chrome/css/main.css | 27 ++++++++++++ ui/chrome/index.html | 22 ++++++++++ ui/chrome/js/console.js | 18 ++++++++ ui/chrome/js/moddashboard.js | 0 ui/chrome/moddashboard/index.html | 28 ++++++++++++ ui/externalScripts/console_preload.js | 14 ++++++ ui/externalScripts/main_preload.js | 7 +++ ui_index.cjs | 63 +++++++++++++++++++++++++++ 9 files changed, 201 insertions(+) create mode 100644 ui/chrome/console.html create mode 100644 ui/chrome/css/main.css create mode 100644 ui/chrome/index.html create mode 100644 ui/chrome/js/console.js create mode 100644 ui/chrome/js/moddashboard.js create mode 100644 ui/chrome/moddashboard/index.html create mode 100644 ui/externalScripts/console_preload.js create mode 100644 ui/externalScripts/main_preload.js create mode 100644 ui_index.cjs diff --git a/ui/chrome/console.html b/ui/chrome/console.html new file mode 100644 index 0000000..4d370d4 --- /dev/null +++ b/ui/chrome/console.html @@ -0,0 +1,22 @@ + + + + + + Console + + + + +
+

Console

+
+
+
+ +
+
+ + \ No newline at end of file diff --git a/ui/chrome/css/main.css b/ui/chrome/css/main.css new file mode 100644 index 0000000..6b0d48d --- /dev/null +++ b/ui/chrome/css/main.css @@ -0,0 +1,27 @@ +body { + background-color: #ffffff; + font-family: Arial, sans-serif; + color: #333; +} + +#terminal { + width: 100%; + height: 300px; /* Adjusted height for better visibility */ + overflow-y: auto; + background-color: #000; + color: #fff; + font-family: monospace; + border: 1px solid #ccc; + padding: 10px; + box-sizing: border-box; + margin-top: 20px; /* Added spacing from other elements */ +} + +#terminal pre { + margin: 0; + padding: 0; +} + +#terminal pre .error { + color: red; +} \ No newline at end of file diff --git a/ui/chrome/index.html b/ui/chrome/index.html new file mode 100644 index 0000000..97151b5 --- /dev/null +++ b/ui/chrome/index.html @@ -0,0 +1,22 @@ + + + + + + MC Launcher + + + +
+

MC Launcher

+
+
+ + Mod Dashboard +
+ + \ No newline at end of file diff --git a/ui/chrome/js/console.js b/ui/chrome/js/console.js new file mode 100644 index 0000000..f58f3e2 --- /dev/null +++ b/ui/chrome/js/console.js @@ -0,0 +1,18 @@ +mcAPI.onMessage((msg) => { + const c = document.getElementById('terminal'); + const newLine = document.createElement('pre'); + console.log(msg); + newLine.innerText = msg; + c.appendChild(newLine); + c.scrollTop = c.scrollHeight; +}); + +mcAPI.onError((msg) => { + const c = document.getElementById('terminal'); + const newLine = document.createElement('pre'); + console.error(msg); + newLine.innerText = msg; + newLine.className = 'error' + c.appendChild(newLine); + c.scrollTop = c.scrollHeight; +}); \ No newline at end of file diff --git a/ui/chrome/js/moddashboard.js b/ui/chrome/js/moddashboard.js new file mode 100644 index 0000000..e69de29 diff --git a/ui/chrome/moddashboard/index.html b/ui/chrome/moddashboard/index.html new file mode 100644 index 0000000..3aa0c47 --- /dev/null +++ b/ui/chrome/moddashboard/index.html @@ -0,0 +1,28 @@ + + + + + + Mod Dashboard + + + +
+

Mod Dashboard

+
+
+
+

Installed Mods

+ +
+
+

Mod Details

+
+ +
+
+
+ + \ No newline at end of file diff --git a/ui/externalScripts/console_preload.js b/ui/externalScripts/console_preload.js new file mode 100644 index 0000000..3690c58 --- /dev/null +++ b/ui/externalScripts/console_preload.js @@ -0,0 +1,14 @@ +const {ipcRenderer,contextBridge} = require('electron'); + +contextBridge.exposeInMainWorld('mcAPI', { + onMessage: (callback) => { + ipcRenderer.on('msg', (event, arg) => { + callback(arg); + }); + }, + onError: (callback) => { + ipcRenderer.on('error', (event, arg) => { + callback(arg); + }); + } +}); \ No newline at end of file diff --git a/ui/externalScripts/main_preload.js b/ui/externalScripts/main_preload.js new file mode 100644 index 0000000..74c0498 --- /dev/null +++ b/ui/externalScripts/main_preload.js @@ -0,0 +1,7 @@ +const {ipcRenderer,contextBridge} = require('electron'); + +contextBridge.exposeInMainWorld('mcAPI', { + launch: () => { + ipcRenderer.send('launch'); + } +}) \ No newline at end of file diff --git a/ui_index.cjs b/ui_index.cjs new file mode 100644 index 0000000..96c8006 --- /dev/null +++ b/ui_index.cjs @@ -0,0 +1,63 @@ +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const { spawn } = require('child_process'); +const mainIndex = path.join(__dirname, 'index.js') + +const createWindow = () => { + const win = new BrowserWindow({ + width: 800, + height: 600, + webPreferences: { + preload: path.join(__dirname, 'ui', 'externalScripts', 'main_preload.js') + } + }); + + win.loadFile(path.join(__dirname, 'ui', 'chrome', 'index.html')); +} + +const createConsole = () => { + const consoleWin = new BrowserWindow({ + width: 800, + height: 600, + webPreferences: { + preload: path.join(__dirname, 'ui', 'externalScripts', 'console_preload.js') + } + }); + + consoleWin.loadFile(path.join(__dirname, 'ui', 'chrome', 'console.html')); + return consoleWin +} + +app.whenReady().then(() => { + ipcMain.on('launch', (event, arg) => { + let consoleWin = createConsole(); + const process = spawn('node', [mainIndex]); + + process.stdout.on('data', (data) => { + consoleWin.webContents.send('msg', String(data)); // Send to renderer + console.log(`stdout: ${data}`); + }); + + process.stderr.on('data', (data) => { + consoleWin.webContents.send('error', String(data)); // Send to renderer + console.error(`stderr: ${data}`); + }); + + process.on('close', (code) => { + console.log(`child process exited with code ${code}`); + }); + + process.on('error', (error) => { + console.error(`Error: ${error}`); + }); + + process.on('exit', (code) => { + console.log(`Process exited with code: ${code}`); + event.reply('process-exit', code); + consoleWin.close(); + consoleWin = null; + }); + }); + createWindow(); +}); \ No newline at end of file From 60ca56538ece7dc4a1e5298e69d97794598528df Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Tue, 6 May 2025 09:25:04 +0200 Subject: [PATCH 02/19] Port to python --- .gitignore | 3 +- 1.21.1.json | 56 --- __main__.py | 1002 +++++++++++++++++++++++++++++++++++++++++++++++++++ java.py | 428 ++++++++++++++++++++++ replacer.py | 68 ++++ 5 files changed, 1500 insertions(+), 57 deletions(-) create mode 100644 __main__.py create mode 100644 java.py create mode 100644 replacer.py diff --git a/.gitignore b/.gitignore index 6393361..874c3ea 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ config.json java-runtime .DS_Store dist -build \ No newline at end of file +build +__pycache__ \ No newline at end of file diff --git a/1.21.1.json b/1.21.1.json index 20b4bc2..fcfa979 100644 --- a/1.21.1.json +++ b/1.21.1.json @@ -49,62 +49,6 @@ "--height", "${resolution_height}" ] - }, - { - "rules": [ - { - "action": "allow", - "features": { - "has_quick_plays_support": true - } - } - ], - "value": [ - "--quickPlayPath", - "${quickPlayPath}" - ] - }, - { - "rules": [ - { - "action": "allow", - "features": { - "is_quick_play_singleplayer": true - } - } - ], - "value": [ - "--quickPlaySingleplayer", - "${quickPlaySingleplayer}" - ] - }, - { - "rules": [ - { - "action": "allow", - "features": { - "is_quick_play_multiplayer": true - } - } - ], - "value": [ - "--quickPlayMultiplayer", - "${quickPlayMultiplayer}" - ] - }, - { - "rules": [ - { - "action": "allow", - "features": { - "is_quick_play_realms": true - } - } - ], - "value": [ - "--quickPlayRealms", - "${quickPlayRealms}" - ] } ], "jvm": [ diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..f8fc65b --- /dev/null +++ b/__main__.py @@ -0,0 +1,1002 @@ +# main.py +import os +import platform +import pathlib +import json +import hashlib +import asyncio +import logging +import sys +import zipfile +import shutil # For copytree and rmtree +from typing import Dict, Any, List, Optional, Union + +import aiohttp +import aiofiles +import aiofiles.os +from tqdm.asyncio import tqdm # Use tqdm's async version + +# --- Local Imports --- +try: + # Assuming java.py and replacer.py are in the same directory + from java import download_java + from replacer import replace_text +except ImportError as e: + print(f"Error importing local modules (java.py, replacer.py): {e}") + print("Please ensure these files are in the same directory as main.py.") + sys.exit(1) + +# --- Logging Setup --- +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +log = logging.getLogger(__name__) + +# --- Constants and Configuration --- +DEFAULT_VERSION_MANIFEST = 'neoforge-21.1.162.json' # Default if not in launcher_config +SCRIPT_DIR = pathlib.Path(__file__).parent.resolve() + +# Load launcher_config.json and patch paths +try: + with open(SCRIPT_DIR / "launcher_config.json", 'r') as f: + launcher_config_raw = json.load(f) +except FileNotFoundError: + log.error("launcher_config.json not found in the script directory.") + sys.exit(1) +except json.JSONDecodeError as e: + log.error(f"Error parsing launcher_config.json: {e}") + sys.exit(1) + +# Patch launcher config immediately (replace_text is synchronous) +launcher_config = {} +for key, value in launcher_config_raw.items(): + launcher_config[key] = replace_text(value, {':thisdir:': str(SCRIPT_DIR)}) + +log.info(f"Launcher config: {json.dumps(launcher_config, indent=2)}") + +# Main target version manifest filename from launcher_config or default +TARGET_VERSION_MANIFEST_FILENAME = launcher_config.get('version', DEFAULT_VERSION_MANIFEST) + +# Load user config (config.json) if it exists +cfg = {} +try: + config_path = SCRIPT_DIR / 'config.json' + if config_path.exists(): + # Reading small config at start is often acceptable synchronously. + with open(config_path, 'r', encoding='utf-8') as f: + cfg = json.load(f) +except json.JSONDecodeError as e: + log.warning(f"Could not parse config.json: {e}. Using defaults.") +except Exception as e: + log.warning(f"Could not read config.json: {e}. Using defaults.") + +# --- Authentication Details --- +AUTH_PLAYER_NAME = cfg.get("auth_player_name") if cfg.get("auth_player_name") else 'Player' +AUTH_UUID = cfg.get("auth_uuid") if cfg.get("auth_uuid") else '00000000-0000-0000-0000-000000000000' +AUTH_ACCESS_TOKEN = cfg.get("auth_access_token") if cfg.get("auth_access_token") else '00000000000000000000000000000000' +AUTH_XUID = cfg.get("auth_xuid") if cfg.get("auth_xuid") else '0' +USER_TYPE = 'msa' + +# --- Directories --- +BASE_PATH = pathlib.Path(launcher_config.get('basepath', SCRIPT_DIR / '.mc_launcher_data')) +MINECRAFT_DIR = BASE_PATH / launcher_config.get('path', '.minecraft') +VERSIONS_DIR = MINECRAFT_DIR / 'versions' +LIBRARIES_DIR = MINECRAFT_DIR / 'libraries' +ASSETS_DIR = MINECRAFT_DIR / 'assets' +ASSET_INDEXES_DIR = ASSETS_DIR / 'indexes' +ASSET_OBJECTS_DIR = ASSETS_DIR / 'objects' +LAUNCHER_PROFILES_PATH = MINECRAFT_DIR / 'launcher_profiles.json' +CLIENT_STORAGE_PATH = MINECRAFT_DIR / 'client_storage.json' +BACKUP_PATH_BASE = SCRIPT_DIR / '.minecraft_autobackup' +JAVA_INSTALL_DIR = SCRIPT_DIR / 'java-runtime' + +# --- Helper Functions --- + +async def get_file_sha1(file_path: pathlib.Path) -> str: + """Calculates the SHA1 hash of a file asynchronously.""" + sha1_hash = hashlib.sha1() + try: + async with aiofiles.open(file_path, 'rb') as f: + while True: + chunk = await f.read(8192) + if not chunk: + break + sha1_hash.update(chunk) + return sha1_hash.hexdigest() + except FileNotFoundError: + raise FileNotFoundError(f"File not found for SHA1 calculation: {file_path}") + except Exception as e: + raise RuntimeError(f"Error calculating SHA1 for {file_path}: {e}") + +async def file_exists(file_path: pathlib.Path) -> bool: + """Checks if a file exists asynchronously.""" + try: + stats = await aiofiles.os.stat(file_path) + # Check if it's a regular file using stat results + return stats.st_mode & 0o100000 != 0 + except OSError: # Includes FileNotFoundError + return False + +# Shared aiohttp session +AIOHTTP_SESSION = None + +async def get_session(): + global AIOHTTP_SESSION + if AIOHTTP_SESSION is None or AIOHTTP_SESSION.closed: + AIOHTTP_SESSION = aiohttp.ClientSession() + return AIOHTTP_SESSION + +async def close_session(): + global AIOHTTP_SESSION + if AIOHTTP_SESSION and not AIOHTTP_SESSION.closed: + await AIOHTTP_SESSION.close() + AIOHTTP_SESSION = None + +async def download_file( + url: str, + dest_path: pathlib.Path, + expected_sha1: Optional[str], + force_download: bool = False, + pbar: Optional[tqdm] = None # Progress bar instance passed in +) -> bool: + """Downloads a file asynchronously, verifies SHA1 hash.""" + dir_path = dest_path.parent + await aiofiles.os.makedirs(dir_path, exist_ok=True) + + needs_download = force_download + exists = await file_exists(dest_path) + + if exists and not force_download: + if expected_sha1: + try: + current_sha1 = await get_file_sha1(dest_path) + if current_sha1.lower() == expected_sha1.lower(): + if pbar: pbar.update(1) # Update progress even if skipped + return False # No download needed + else: + log.warning(f"SHA1 mismatch for existing file {dest_path.name}. Expected {expected_sha1}, got {current_sha1}. Redownloading.") + needs_download = True + except Exception as hash_error: + log.warning(f"Warning: Could not hash existing file {dest_path}. Redownloading. Error: {hash_error}") + needs_download = True + else: + if pbar: pbar.update(1) + return False # File exists, no hash check, no download needed + elif not exists: + needs_download = True + + if not needs_download: + if pbar: pbar.update(1) # Ensure progress updates if no download needed + return False + + session = await get_session() + try: + async with session.get(url) as response: + if not response.ok: + # Clean up potentially corrupted file before throwing + try: + if await aiofiles.os.path.exists(dest_path): await aiofiles.os.remove(dest_path) + except OSError: pass # Ignore deletion errors + raise aiohttp.ClientResponseError( + response.request_info, response.history, + status=response.status, message=f"Failed to download {url}: {response.reason}", headers=response.headers + ) + # Stream download to file + async with aiofiles.open(dest_path, 'wb') as f: + async for chunk in response.content.iter_chunked(8192): + await f.write(chunk) + + if expected_sha1: + downloaded_sha1 = await get_file_sha1(dest_path) + if downloaded_sha1.lower() != expected_sha1.lower(): + raise ValueError(f"SHA1 mismatch for {dest_path.name}. Expected {expected_sha1}, got {downloaded_sha1}") + + if pbar: pbar.update(1) # Update progress after successful download + return True # Download occurred + + except Exception as error: + log.error(f"Error downloading {url}: {error}") + # Clean up potentially incomplete file + try: + if await aiofiles.os.path.exists(dest_path): await aiofiles.os.remove(dest_path) + except OSError: pass + if pbar: pbar.close() # Stop progress bar on error + raise # Re-throw to stop the process + +# Sync zip extraction (run in executor) +def _extract_zip_sync(jar_path: pathlib.Path, extract_to_dir: pathlib.Path): + try: + with zipfile.ZipFile(jar_path, 'r') as zip_ref: + for member in zip_ref.infolist(): + # Skip directories and META-INF + if not member.is_dir() and not member.filename.upper().startswith('META-INF/'): + try: + # Preserve directory structure within the zip + zip_ref.extract(member, extract_to_dir) + except Exception as extract_error: + log.warning(f"Warning: Could not extract {member.filename} from {jar_path.name}. Error: {extract_error}") + except zipfile.BadZipFile: + log.error(f"Failed to read zip file (BadZipFile): {jar_path}") + raise + except Exception as e: + log.error(f"Failed to process zip file {jar_path}: {e}") + raise + +async def extract_natives(jar_path: pathlib.Path, extract_to_dir: pathlib.Path): + """Extracts native libraries from a JAR file asynchronously.""" + await aiofiles.os.makedirs(extract_to_dir, exist_ok=True) + loop = asyncio.get_running_loop() + # Run the synchronous extraction function in a thread pool executor + await loop.run_in_executor(None, _extract_zip_sync, jar_path, extract_to_dir) + + +def get_os_name() -> str: + """Gets the current OS name ('windows', 'osx', 'linux').""" + system = platform.system() + if system == 'Windows': return 'windows' + elif system == 'Darwin': return 'osx' + elif system == 'Linux': return 'linux' + else: raise OSError(f"Unsupported platform: {system}") + +def get_arch_name() -> str: + """Gets the current architecture name ('x64', 'x86', 'arm64', 'arm32').""" + machine = platform.machine().lower() + if machine in ['amd64', 'x86_64']: return 'x64' + elif machine in ['i386', 'i686']: return 'x86' + elif machine in ['arm64', 'aarch64']: return 'arm64' + elif machine.startswith('arm') and '64' not in machine: return 'arm32' + else: + # Fallback or error for less common architectures + log.warning(f"Unsupported architecture: {platform.machine()}. Falling back to 'x64'. This might cause issues.") + return 'x64' + + +# --- Rule Processing --- + +def check_rule(rule: Optional[Dict[str, Any]]) -> bool: + """ + Checks if a *single rule* permits an item based on the current environment. + Returns True if the rule permits inclusion, False otherwise. + """ + if not rule or 'action' not in rule: + return True # Default allow if no rule/action specified + + action = rule.get('action', 'allow') + applies = True # Assume the condition matches unless proven otherwise + + # Check OS condition + if 'os' in rule and isinstance(rule['os'], dict): + os_rule = rule['os'] + current_os = get_os_name() + current_arch = get_arch_name() + if 'name' in os_rule and os_rule['name'] != current_os: + applies = False + if applies and 'arch' in os_rule and os_rule['arch'] != current_arch: + applies = False + # Version check omitted for simplicity + + # Check features condition + if applies and 'features' in rule and isinstance(rule['features'], dict): + features_rule = rule['features'] + # Get feature flags from config or use defaults + is_demo = cfg.get('demo', False) + has_custom_res = True # Assume true like JS example, maybe get from cfg? + + # Check specific features mentioned in the rule. + # If *any* specified feature condition is NOT met, 'applies' becomes False. + if 'is_demo_user' in features_rule: + if features_rule['is_demo_user'] != is_demo: + applies = False + if applies and 'has_custom_resolution' in features_rule: + if features_rule['has_custom_resolution'] != has_custom_res: + applies = False + # Add other feature checks here based on rule.features keys similarly... + + # Evaluate the rule's final outcome based on action and condition match + if action == 'allow': + # Allow action: Item permitted only if conditions apply + return applies + elif action == 'disallow': + # Disallow action: Item permitted only if conditions *do not* apply + return not applies + else: + log.warning(f"Unknown rule action: {action}. Defaulting to allow.") + return True + +def check_item_rules(rules: Optional[List[Dict[str, Any]]]) -> bool: + """ + Checks if an item (library/argument) should be included based on its rules array. + Implements the logic: Disallow if *any* rule prevents inclusion. + """ + if not rules: + return True # No rules, always include (default allow) + + # Assume allowed unless a rule forbids it + allowed = True + for rule in rules: + # check_rule returns True if this specific rule *permits* inclusion + if not check_rule(rule): + # If check_rule is False, this rule prevents inclusion + allowed = False + # log.debug(f"Item disallowed by rule: {rule}") # Optional debug + break # No need to check further rules, it's disallowed + + return allowed + +# --- End Rule Processing --- + + +async def ensure_launcher_profiles(current_version_id: str): + """Ensures launcher_profiles.json exists and contains basic info.""" + log.info('Checking launcher profiles...') + profile_name = f"custom-{current_version_id}" + # Generate profile key from UUID (consistent with vanilla launcher) + auth_profile_key = AUTH_UUID.replace('-', '') + account_key = f"account-{auth_profile_key}" # Used in newer profile formats + + # Basic structure + profiles_data = { + "profiles": { + profile_name: { + # "created": datetime.now().isoformat(), # Optional + # "icon": "Furnace", # Optional + "lastUsed": "1970-01-01T00:00:00.000Z", # Needs update on launch + "lastVersionId": current_version_id, + "name": profile_name, + "type": "custom" + # "javaArgs": "-Xmx2G", # Optional + # "gameDir": str(MINECRAFT_DIR) # Optional + } + }, + "authenticationDatabase": { + account_key: { + "accessToken": AUTH_ACCESS_TOKEN, + "profiles": { + auth_profile_key: { + "displayName": AUTH_PLAYER_NAME, + "playerUUID": AUTH_UUID, + "userId": AUTH_XUID, + # "texture": "..." # Optional base64 skin/cape + } + }, + "username": AUTH_PLAYER_NAME, # Often email for MSA + "properties": [], # Optional user properties + # "remoteId": "...", # Xbox user ID + } + }, + "settings": { + # "locale": "en-us", + # "showMenu": True, + # ... other launcher settings + }, + "selectedUser": { + "account": account_key, + "profile": auth_profile_key + }, + "version": 4 # Common version number for this format + } + try: + # Ensure directory exists first + await aiofiles.os.makedirs(LAUNCHER_PROFILES_PATH.parent, exist_ok=True) + # Write the file asynchronously + async with aiofiles.open(LAUNCHER_PROFILES_PATH, 'w', encoding='utf-8') as f: + await f.write(json.dumps(profiles_data, indent=2)) + log.info(f"Created/updated {LAUNCHER_PROFILES_PATH}") + except Exception as error: + log.error(f"Failed to write {LAUNCHER_PROFILES_PATH}: {error}") + raise RuntimeError("Could not write launcher profiles file.") + + +async def load_manifest(filename: str) -> Dict[str, Any]: + """Loads a JSON manifest file from the script's directory asynchronously.""" + file_path = SCRIPT_DIR / filename + log.info(f"Loading manifest: {filename}") + try: + async with aiofiles.open(file_path, 'r', encoding='utf-8') as f: + content = await f.read() + return json.loads(content) + except FileNotFoundError: + log.error(f"Manifest file not found: {file_path}") + raise + except json.JSONDecodeError as e: + log.error(f"Failed to parse manifest {filename}: {e}") + raise ValueError(f"Invalid JSON in manifest: {filename}") + except Exception as e: + log.error(f"Failed to load manifest {filename}: {e}") + raise + +def merge_manifests(target_manifest: Dict[str, Any], base_manifest: Dict[str, Any]) -> Dict[str, Any]: + """Merges two version manifests (target inheriting from base).""" + target_id = target_manifest.get('id', 'unknown-target') + base_id = base_manifest.get('id', 'unknown-base') + log.info(f"Merging manifests: {target_id} inheriting from {base_id}") + + # Combine libraries: Use a dictionary keyed by 'name' for overrides. + combined_libraries_map = {} + for lib in base_manifest.get('libraries', []): + if 'name' in lib: combined_libraries_map[lib['name']] = lib + for lib in target_manifest.get('libraries', []): + if 'name' in lib: combined_libraries_map[lib['name']] = lib # Overwrite + + # Combine arguments: Append target args to base args. + base_args = base_manifest.get('arguments', {}) or {} + target_args = target_manifest.get('arguments', {}) or {} + combined_arguments = { + "game": (base_args.get('game', []) or []) + (target_args.get('game', []) or []), + "jvm": (base_args.get('jvm', []) or []) + (target_args.get('jvm', []) or []) + } + + # Construct merged manifest, prioritizing target values. + merged = { + "id": target_manifest.get('id'), + "time": target_manifest.get('time'), + "releaseTime": target_manifest.get('releaseTime'), + "type": target_manifest.get('type'), + "mainClass": target_manifest.get('mainClass'), # Target overrides + "assetIndex": target_manifest.get('assetIndex', base_manifest.get('assetIndex')), # Prefer target + "assets": target_manifest.get('assets', base_manifest.get('assets')), # Asset ID string + "downloads": base_manifest.get('downloads'), # Use base downloads (client.jar etc.) + "javaVersion": target_manifest.get('javaVersion', base_manifest.get('javaVersion')), # Prefer target + "libraries": list(combined_libraries_map.values()), # Convert dict values back to list + "arguments": combined_arguments, + "logging": target_manifest.get('logging', base_manifest.get('logging')), # Prefer target + "complianceLevel": target_manifest.get('complianceLevel', base_manifest.get('complianceLevel')), + "minimumLauncherVersion": target_manifest.get('minimumLauncherVersion', base_manifest.get('minimumLauncherVersion')) + } + # Clean up None values from .get() fallbacks + return {k: v for k, v in merged.items() if v is not None} + +# Sync backup function (to be run in executor) +def _create_backup_sync(source_dir: pathlib.Path, backup_dir_base: pathlib.Path): + """Copies source to backup dir, zips it, then removes backup dir.""" + backup_dir = backup_dir_base # Directory to copy into first + backup_zip_path = backup_dir_base.with_suffix('.zip') + + log.info(f"Starting backup copy from {source_dir} to {backup_dir}") + # Remove old backup directory/zip if they exist + if backup_zip_path.exists(): + log.debug(f"Removing existing backup zip: {backup_zip_path}") + backup_zip_path.unlink() + if backup_dir.exists(): + log.debug(f"Removing existing intermediate backup dir: {backup_dir}") + shutil.rmtree(backup_dir) + + # Copy the entire directory + shutil.copytree(source_dir, backup_dir, dirs_exist_ok=True) + log.info(f"Backup copy complete. Zipping {backup_dir} to {backup_zip_path}") + + # Zip the backup directory + with zipfile.ZipFile(backup_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + for root, _, files in os.walk(backup_dir): + for file in files: + file_path = pathlib.Path(root) / file + # Arcname is the path inside the zip file relative to backup_dir + arcname = file_path.relative_to(backup_dir) + zipf.write(file_path, arcname) + log.info(f"Backup zip created at {backup_zip_path}") + + # Remove the temporary backup directory after zipping + shutil.rmtree(backup_dir) + log.info(f"Removed intermediate backup directory {backup_dir}") + +# --- Main Execution --- +async def main(): + try: + # 1. Load Target Manifest + target_manifest = await load_manifest(TARGET_VERSION_MANIFEST_FILENAME) + target_version_id = target_manifest.get('id') + if not target_version_id: + raise ValueError(f"Target manifest {TARGET_VERSION_MANIFEST_FILENAME} is missing required 'id' field.") + + # 2. Load Base Manifest if needed and Merge + final_manifest = target_manifest + if 'inheritsFrom' in target_manifest: + base_version_id = target_manifest['inheritsFrom'] + base_manifest_filename = f"{base_version_id}.json" + try: + base_manifest = await load_manifest(base_manifest_filename) + final_manifest = merge_manifests(target_manifest, base_manifest) + except (FileNotFoundError, ValueError, KeyError, Exception) as e: + log.error(f"Could not load or merge base manifest '{base_manifest_filename}' specified in {TARGET_VERSION_MANIFEST_FILENAME}: {e}") + sys.exit(1) + else: + log.info(f"Manifest {target_version_id} does not inherit from another version.") + + # --- Use finalManifest for all subsequent steps --- + version_id = final_manifest.get('id') # Should be the target ID after merge + if not version_id: + raise ValueError("Final merged manifest is missing the 'id' field.") + + version_dir = VERSIONS_DIR / version_id + natives_dir = version_dir / f"{version_id}-natives" + + log.info(f"Preparing Minecraft {version_id}...") + os_name = get_os_name() + arch_name = get_arch_name() + log.info(f"Detected OS: {os_name}, Arch: {arch_name}") + + # 4. Ensure Directories and Launcher Profiles + log.info(f"Ensuring base directory exists: {MINECRAFT_DIR}") + # Create all necessary directories asynchronously and concurrently + await asyncio.gather( + aiofiles.os.makedirs(MINECRAFT_DIR, exist_ok=True), + aiofiles.os.makedirs(VERSIONS_DIR, exist_ok=True), + aiofiles.os.makedirs(LIBRARIES_DIR, exist_ok=True), + aiofiles.os.makedirs(ASSETS_DIR, exist_ok=True), + aiofiles.os.makedirs(version_dir, exist_ok=True), + aiofiles.os.makedirs(natives_dir, exist_ok=True), + aiofiles.os.makedirs(ASSET_INDEXES_DIR, exist_ok=True), + aiofiles.os.makedirs(ASSET_OBJECTS_DIR, exist_ok=True) + ) + # Create/update launcher profiles + await ensure_launcher_profiles(version_id) + + # 5. Copy *Target* Version Manifest JSON to Version Directory + target_manifest_source_path = SCRIPT_DIR / TARGET_VERSION_MANIFEST_FILENAME + dest_manifest_path = version_dir / f"{version_id}.json" + try: + log.info(f"Copying {target_manifest_source_path.name} to {dest_manifest_path}") + # Run synchronous copy in executor to avoid blocking + await asyncio.get_running_loop().run_in_executor( + None, shutil.copyfile, target_manifest_source_path, dest_manifest_path + ) + except Exception as error: + log.error(f"Failed to copy target version manifest: {error}") + raise RuntimeError(f"Could not copy version manifest file: {target_manifest_source_path}") + + # 6. Download Client JAR + log.info('Checking client JAR...') + client_info = final_manifest.get('downloads', {}).get('client') + if not (client_info and 'url' in client_info and 'sha1' in client_info): + raise ValueError(f"Merged manifest for {version_id} is missing client download information (url, sha1).") + client_jar_path = version_dir / f"{version_id}.jar" + # Use tqdm context for the single download (async with not supported, manual create/close) + client_pbar = tqdm(total=1, desc="Client JAR", unit="file", leave=False) + try: + await download_file(client_info['url'], client_jar_path, client_info['sha1'], pbar=client_pbar) + finally: + client_pbar.close() + + + # 7. Prepare Library List + log.info('Processing library list...') + libraries_to_process = [] + classpath_entries_set = {str(client_jar_path)} # Use a set to avoid duplicates + native_library_paths = [] + + for lib in final_manifest.get('libraries', []): + # Check rules for the entire library entry FIRST + if not check_item_rules(lib.get('rules')): + # log.debug(f"Skipping library due to overall rules: {lib.get('name', 'N/A')}") + continue + + lib_name = lib.get('name', 'unknown-library') + downloads = lib.get('downloads', {}) + artifact = downloads.get('artifact') + classifiers = downloads.get('classifiers', {}) + natives_rules = lib.get('natives', {}) # Legacy natives mapping + + # --- Determine Native Classifier --- + native_classifier_key = None + native_info = None + # Check 'natives' mapping first (less common now) + if os_name in natives_rules: + raw_classifier = natives_rules[os_name] + # Replace ${arch} - Python needs specific replacement logic + arch_replace = '64' if arch_name == 'x64' else ('32' if arch_name == 'x86' else arch_name) + potential_key = raw_classifier.replace('${arch}', arch_replace) + if potential_key in classifiers: + native_classifier_key = potential_key + native_info = classifiers[native_classifier_key] + # log.debug(f"Found native classifier via 'natives' rule: {native_classifier_key}") + + # Check standard 'classifiers' if not found via 'natives' + if not native_info and classifiers: + # Construct potential keys based on current OS/Arch + potential_keys = [ + f"natives-{os_name}-{arch_name}", + f"natives-{os_name}", + ] + for key in potential_keys: + if key in classifiers: + native_classifier_key = key + native_info = classifiers[key] + # log.debug(f"Found native classifier via standard key: {key}") + break + + # --- Add Main Artifact --- + if artifact and artifact.get('path') and artifact.get('url'): + # Rules specific to the artifact itself are not standard in manifests. + # We rely on the top-level library rules check done earlier. + lib_path = LIBRARIES_DIR / artifact['path'] + libraries_to_process.append({ + "name": lib_name, + "url": artifact['url'], + "path": lib_path, + "sha1": artifact.get('sha1'), + "is_native": False, + }) + classpath_entries_set.add(str(lib_path)) # Add non-native to classpath + + # --- Add Native Artifact --- + if native_info and native_info.get('path') and native_info.get('url'): + # Again, rely on top-level library rules. Classifier-specific rules aren't standard. + native_path = LIBRARIES_DIR / native_info['path'] + libraries_to_process.append({ + "name": f"{lib_name}:{native_classifier_key}", # Include classifier in name for clarity + "url": native_info['url'], + "path": native_path, + "sha1": native_info.get('sha1'), + "is_native": True, + }) + native_library_paths.append(native_path) # Keep track of native JARs for extraction + + # Download Libraries (Corrected tqdm usage) + log.info(f"Downloading {len(libraries_to_process)} library files...") + lib_pbar = tqdm(total=len(libraries_to_process), desc="Libraries", unit="file", leave=False) + try: + download_tasks = [ + download_file(lib_info['url'], lib_info['path'], lib_info['sha1'], pbar=lib_pbar) + for lib_info in libraries_to_process + ] + await asyncio.gather(*download_tasks) + finally: + lib_pbar.close() + log.info('Library download check complete.') + + classpath_entries = list(classpath_entries_set) # Convert classpath set back to list + + # 8. Extract Natives + log.info('Extracting native libraries...') + # Clear existing natives directory first + try: + if await aiofiles.os.path.isdir(natives_dir): + log.debug(f"Removing existing natives directory: {natives_dir}") + # Run synchronous rmtree in executor + await asyncio.get_running_loop().run_in_executor(None, shutil.rmtree, natives_dir) + await aiofiles.os.makedirs(natives_dir, exist_ok=True) + except Exception as err: + log.warning(f"Could not clear/recreate natives directory {natives_dir}: {err}. Extraction might fail or use old files.") + + if native_library_paths: + # Corrected tqdm usage for natives + native_pbar = tqdm(total=len(native_library_paths), desc="Natives", unit="file", leave=False) + try: + extract_tasks = [] + for native_jar_path in native_library_paths: + # Define task within loop to capture correct native_jar_path + async def extract_task(jar_path, pbar_instance): + try: + await extract_natives(jar_path, natives_dir) + except Exception as e: + log.error(f"\nFailed to extract natives from: {jar_path.name}: {e}") + # Decide if you want to raise or just log + # raise # Uncomment to stop on first extraction error + finally: + pbar_instance.update(1) + + extract_tasks.append(extract_task(native_jar_path, native_pbar)) + await asyncio.gather(*extract_tasks) + finally: + native_pbar.close() # Ensure pbar is closed + else: + log.info("No native libraries to extract for this platform.") + log.info('Native extraction complete.') + + + # 9. Download Assets + log.info('Checking assets...') + asset_index_info = final_manifest.get('assetIndex') + if not (asset_index_info and 'id' in asset_index_info and 'url' in asset_index_info and 'sha1' in asset_index_info): + raise ValueError(f"Merged manifest for {version_id} is missing asset index information (id, url, sha1).") + + asset_index_id = asset_index_info['id'] + asset_index_filename = f"{asset_index_id}.json" + asset_index_path = ASSET_INDEXES_DIR / asset_index_filename + + # Download asset index (Corrected tqdm usage) + idx_pbar = tqdm(total=1, desc="Asset Index", unit="file", leave=False) + try: + await download_file(asset_index_info['url'], asset_index_path, asset_index_info['sha1'], pbar=idx_pbar) + finally: + idx_pbar.close() + + # Load asset index content + try: + async with aiofiles.open(asset_index_path, 'r', encoding='utf-8') as f: + asset_index_content = json.loads(await f.read()) + except Exception as e: + raise RuntimeError(f"Failed to read downloaded asset index {asset_index_path}: {e}") + + asset_objects = asset_index_content.get('objects', {}) + total_assets = len(asset_objects) + log.info(f"Checking {total_assets} asset files listed in index {asset_index_id}...") + + # Download assets (Corrected tqdm usage) + asset_pbar = tqdm(total=total_assets, desc="Assets", unit="file", leave=False) + try: + asset_download_tasks = [] + for asset_key, asset_details in asset_objects.items(): + asset_hash = asset_details.get('hash') + if not asset_hash: + log.warning(f"Asset '{asset_key}' is missing hash in index, skipping.") + asset_pbar.update(1); continue # Still count it towards progress + + hash_prefix = asset_hash[:2] + asset_subdir = ASSET_OBJECTS_DIR / hash_prefix + asset_filepath = asset_subdir / asset_hash + # Standard Minecraft asset download URL structure + asset_url = f"https://resources.download.minecraft.net/{hash_prefix}/{asset_hash}" + asset_download_tasks.append( + download_file(asset_url, asset_filepath, asset_hash, pbar=asset_pbar) + ) + await asyncio.gather(*asset_download_tasks) + finally: + asset_pbar.close() # Ensure pbar is closed + log.info('Asset check complete.') + + # 10. Download and Setup Java Runtime + java_version_info = final_manifest.get('javaVersion') + if not (java_version_info and 'majorVersion' in java_version_info): + log.warning("Manifest does not specify Java major version. Attempting default.") + required_java_major = DEFAULT_JAVA_VERSION # Use default from java.py + else: + required_java_major = java_version_info['majorVersion'] + + log.info(f"Checking/Installing Java {required_java_major}...") + java_executable = await download_java( + version=required_java_major, + destination_dir_str=str(JAVA_INSTALL_DIR) # Pass as string + # imageType='jre' # Optionally force JRE + ) + + if not java_executable: + log.error(f"Failed to obtain a suitable Java {required_java_major} executable.") + log.error(f"Ensure Java {required_java_major} is installed and accessible, or allow the script to download it to {JAVA_INSTALL_DIR}.") + sys.exit(1) + log.info(f"Using Java executable: {java_executable}") + + + # 11. Handle Client Storage (for NeoForge setup tracking) + log.info("Loading client storage...") + client_storage = {} + try: + if await aiofiles.os.path.exists(CLIENT_STORAGE_PATH): + async with aiofiles.open(CLIENT_STORAGE_PATH, 'r', encoding='utf-8') as f: + client_storage = json.loads(await f.read()) + else: + # Initialize if file doesn't exist + client_storage = {"setupNeoForge": []} # Start with empty list + except json.JSONDecodeError as e: + log.warning(f"Failed to load or parse {CLIENT_STORAGE_PATH}: {e}. Reinitializing.") + client_storage = {"setupNeoForge": []} # Reinitialize on error + except Exception as e: + log.error(f"Error handling {CLIENT_STORAGE_PATH}: {e}. Reinitializing.") + client_storage = {"setupNeoForge": []} + + # Ensure setupNeoForge exists and is a list (migration from old boolean) + if "setupNeoForge" not in client_storage or not isinstance(client_storage.get("setupNeoForge"), list): + client_storage["setupNeoForge"] = [] + + + # 12. Run NeoForge Installer if necessary + needs_neoforge_setup = False + if version_id.startswith("neoforge-") and version_id not in client_storage.get("setupNeoForge", []): + needs_neoforge_setup = True + neoforge_installer_jar = SCRIPT_DIR / 'neoinstaller.jar' + if not await aiofiles.os.path.isfile(neoforge_installer_jar): + log.warning(f"NeoForge version detected ({version_id}), but neoinstaller.jar not found at {neoforge_installer_jar}. Skipping automatic setup.") + needs_neoforge_setup = False # Cannot perform setup + + if needs_neoforge_setup: + log.info(f"Setting up NeoForge for {version_id}...") + # Command structure: java -jar neoinstaller.jar --install-client + setup_command_args = [ + java_executable, + "-jar", + str(neoforge_installer_jar), + "--install-client", + str(MINECRAFT_DIR) # Pass the .minecraft dir path + ] + + log.info(f"Running NeoForge setup command: {' '.join(setup_command_args)}") + # Run the installer process + process = await asyncio.create_subprocess_exec( + *setup_command_args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(SCRIPT_DIR) # Run from script dir to find installer jar easily? + ) + + stdout, stderr = await process.communicate() + + if stdout: log.info("NeoForge Installer Output:\n" + stdout.decode(errors='ignore')) + if stderr: log.error("NeoForge Installer Errors:\n" + stderr.decode(errors='ignore')) + + if process.returncode == 0: + log.info("NeoForge setup completed successfully.") + # Update client storage + client_storage["setupNeoForge"].append(version_id) # Assumes it's a list + try: + async with aiofiles.open(CLIENT_STORAGE_PATH, 'w', encoding='utf-8') as f: + await f.write(json.dumps(client_storage, indent=2)) + except Exception as e: + log.error(f"Failed to update {CLIENT_STORAGE_PATH} after NeoForge setup: {e}") + else: + log.error(f"NeoForge setup failed with exit code {process.returncode}. Minecraft might not launch correctly.") + # Decide if you want to exit here or try launching anyway + # sys.exit(1) + + + # 13. Construct Launch Command + log.info('Constructing launch command...') + classpath_separator = os.pathsep # Use ';' for Windows, ':' for Linux/macOS + classpath_string = classpath_separator.join(classpath_entries) + + # Argument Placeholder Replacements + replacements = { + '${natives_directory}': str(natives_dir), + '${library_directory}': str(LIBRARIES_DIR), + '${classpath_separator}': classpath_separator, + '${launcher_name}': 'CustomPythonLauncher', + '${launcher_version}': '1.0', + '${classpath}': classpath_string, + '${auth_player_name}': AUTH_PLAYER_NAME, + '${version_name}': version_id, + '${game_directory}': str(MINECRAFT_DIR), + '${assets_root}': str(ASSETS_DIR), + '${assets_index_name}': asset_index_id, # Use the ID from assetIndex + '${auth_uuid}': AUTH_UUID, + '${auth_access_token}': AUTH_ACCESS_TOKEN, + '${clientid}': 'N/A', # Placeholder + '${auth_xuid}': AUTH_XUID, + '${user_type}': USER_TYPE, + '${version_type}': final_manifest.get('type', 'release'), # Use manifest type + '${resolution_width}': cfg.get('resolution_width', '854'), + '${resolution_height}': cfg.get('resolution_height', '480'), + } + + def replace_placeholders(arg_template: str) -> str: + """Replaces all placeholders in a single argument string.""" + replaced_arg = arg_template + for key, value in replacements.items(): + replaced_arg = replaced_arg.replace(key, value) + return replaced_arg + + # --- Process JVM Arguments (Using corrected rule logic) --- + jvm_args = [] + for arg_entry in final_manifest.get('arguments', {}).get('jvm', []): + arg_values_to_add = [] # List to hold processed args for this entry + rules = None + process_this_entry = True + + if isinstance(arg_entry, str): + # Simple string argument, implicitly allowed (no rules) + arg_values_to_add.append(replace_placeholders(arg_entry)) + elif isinstance(arg_entry, dict): + # Argument object with potential rules + rules = arg_entry.get('rules') + # Check rules BEFORE processing value + if not check_item_rules(rules): + # log.debug(f"Skipping JVM arg object due to rules: {arg_entry.get('value', '')}") + process_this_entry = False # Skip this whole dict entry + else: + # Rules allow, now process the value(s) + value_from_dict = arg_entry.get('value') + if isinstance(value_from_dict, list): + arg_values_to_add.extend(replace_placeholders(val) for val in value_from_dict) + elif isinstance(value_from_dict, str): + arg_values_to_add.append(replace_placeholders(value_from_dict)) + else: + log.warning(f"Unsupported value type in JVM arg object: {value_from_dict}") + process_this_entry = False + else: + log.warning(f"Unsupported JVM argument format: {arg_entry}") + process_this_entry = False # Skip unknown format + + # Add processed arguments if the entry was allowed and processed + if process_this_entry: + for arg in arg_values_to_add: + # Basic quoting for -D properties with spaces + if arg.startswith("-D") and "=" in arg: + key, value = arg.split("=", 1) + if " " in value and not (value.startswith('"') and value.endswith('"')): + arg = f'{key}="{value}"' + jvm_args.append(arg) + + # --- Process Game Arguments (Using corrected rule logic) --- + game_args = [] + for arg_entry in final_manifest.get('arguments', {}).get('game', []): + arg_values_to_add = [] + rules = None + process_this_entry = True + + if isinstance(arg_entry, str): + arg_values_to_add.append(replace_placeholders(arg_entry)) + elif isinstance(arg_entry, dict): + rules = arg_entry.get('rules') + if not check_item_rules(rules): + # log.debug(f"Skipping game arg object due to rules: {arg_entry.get('value', '')}") + process_this_entry = False + else: + value_from_dict = arg_entry.get('value') + if isinstance(value_from_dict, list): + arg_values_to_add.extend(replace_placeholders(val) for val in value_from_dict) + elif isinstance(value_from_dict, str): + arg_values_to_add.append(replace_placeholders(value_from_dict)) + else: + log.warning(f"Unsupported value type in game arg object: {value_from_dict}") + process_this_entry = False + else: + log.warning(f"Unsupported game argument format: {arg_entry}") + process_this_entry = False + + if process_this_entry: + game_args.extend(arg_values_to_add) + + + # 14. Launch Minecraft + main_class = final_manifest.get('mainClass') + if not main_class: + raise ValueError("Final manifest is missing the 'mainClass' required for launch.") + + final_launch_args = [ + java_executable, + *jvm_args, + main_class, + *game_args, + ] + + log.info("Attempting to launch Minecraft...") + # Optionally log the full command for debugging, but be careful with tokens + # log.debug(f"Launch command: {' '.join(final_launch_args)}") + + # Run the Minecraft process + mc_process = await asyncio.create_subprocess_exec( + *final_launch_args, + stdout=sys.stdout, # Redirect child stdout to parent's stdout + stderr=sys.stderr, # Redirect child stderr to parent's stderr + cwd=MINECRAFT_DIR # Set the working directory to .minecraft + ) + + log.info(f"Minecraft process started (PID: {mc_process.pid}). Waiting for exit...") + + # Wait for the process to complete + return_code = await mc_process.wait() + + # 15. Post-Launch Actions (Backup) + log.info(f"Minecraft process exited with code {return_code}.") + + # Perform backup if configured + if cfg.get("backup", False): + log.info("Backup requested. Creating backup...") + try: + loop = asyncio.get_running_loop() + # Run the synchronous backup function in the executor + await loop.run_in_executor(None, _create_backup_sync, MINECRAFT_DIR, BACKUP_PATH_BASE) + log.info("Backup process completed.") + except Exception as backup_error: + log.error(f"Failed to create backup: {backup_error}", exc_info=True) + else: + log.info("Backup disabled in config.") + + except Exception as e: + log.exception("--- An error occurred during setup or launch ---") + # Optionally add more specific error handling or cleanup + sys.exit(1) + finally: + # Ensure the shared aiohttp session is closed on exit or error + await close_session() + + +# --- Script Entry Point --- +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + log.info("Launch cancelled by user.") + # Ensure session is closed if KeyboardInterrupt happens before finally block in main + # Running close_session within a new asyncio.run context + try: + asyncio.run(close_session()) + except RuntimeError: # Can happen if loop is already closed + pass + # Note: SystemExit from sys.exit() will also terminate the script here \ No newline at end of file diff --git a/java.py b/java.py new file mode 100644 index 0000000..21f9349 --- /dev/null +++ b/java.py @@ -0,0 +1,428 @@ +import os +import platform +import pathlib +import hashlib +import asyncio +import logging +import zipfile +import tarfile +import tempfile +import secrets # For random hex bytes +import aiohttp +import aiofiles +import aiofiles.os + +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +log = logging.getLogger(__name__) + +# --- Configuration --- +ADOPTIUM_API_BASE = 'https://api.adoptium.net/v3' +DEFAULT_JAVA_VERSION = 17 +DEFAULT_IMAGE_TYPE = 'jdk' + +# --- Helper Functions --- + +def get_api_os_arch(): + """Maps Python platform/machine to Adoptium API values.""" + system = platform.system() + machine = platform.machine() + + api_os = None + api_arch = None + + if system == 'Windows': + api_os = 'windows' + elif system == 'Darwin': + api_os = 'mac' + elif system == 'Linux': + api_os = 'linux' + else: + log.error(f"Unsupported operating system: {system}") + return None + + machine = machine.lower() + if machine in ['amd64', 'x86_64']: + api_arch = 'x64' + elif machine in ['arm64', 'aarch64']: + api_arch = 'aarch64' + # Add other mappings if needed (e.g., x86, arm32) + # elif machine in ['i386', 'i686']: + # api_arch = 'x86' + # elif machine.startswith('armv7'): + # api_arch = 'arm' + else: + log.error(f"Unsupported architecture: {machine}") + return None + + return {"os": api_os, "arch": api_arch} + + +# --- Corrected find_java_executable Function --- +async def find_java_executable(extract_dir: pathlib.Path, system: str) -> pathlib.Path | None: + """ + Finds the path to the Java executable within the specified directory. + Checks standard locations based on OS. Corrected scandir usage. + """ + log.info(f"[find_java_executable] Searching in: {extract_dir}") + try: + # Use await for checking the main directory existence (could involve I/O) + if not await aiofiles.os.path.isdir(extract_dir): + log.warning(f"[find_java_executable] Provided path is not a directory: {extract_dir}") + return None + + potential_sub_dir = None + log.debug(f"[find_java_executable] Scanning for subdirectory in {extract_dir}...") + try: + # --- CORRECTED USAGE: Use synchronous os.scandir --- + # No await, no async for. Standard for loop. + for entry in os.scandir(extract_dir): + log.debug(f"[find_java_executable] Found entry: {entry.path} (Name: {entry.name})") + try: + # entry.is_dir() is synchronous + is_dir = entry.is_dir() + log.debug(f"[find_java_executable] Is '{entry.name}' a directory? {is_dir}") + if is_dir: + potential_sub_dir = pathlib.Path(entry.path) + log.info(f"[find_java_executable] Found potential Java subdirectory: {potential_sub_dir}") + break # Assume first directory found is the right one + except OSError as scandir_entry_error: + log.warning(f"[find_java_executable] Could not check directory status for {entry.path}: {scandir_entry_error}") + continue + except OSError as e: + log.warning(f"[find_java_executable] Could not scan directory {extract_dir}: {e}") + # Continue trying base directory paths even if scan fails + + # Determine which directory to check: the found subdir or the base extract dir + base_dir_to_check = potential_sub_dir if potential_sub_dir else extract_dir + log.info(f"[find_java_executable] Selected base directory for final check: {base_dir_to_check}") + + java_executable_path = None + if system == 'Windows': + java_executable_path = base_dir_to_check / 'bin' / 'java.exe' + elif system == 'Darwin': + java_executable_path = base_dir_to_check / 'Contents' / 'Home' / 'bin' / 'java' + else: # Linux + java_executable_path = base_dir_to_check / 'bin' / 'java' + + log.info(f"[find_java_executable] Constructed full path to check: {java_executable_path}") + + # --- Check 1: File Existence (Use await for aiofiles.os.path) --- + try: + is_file = await aiofiles.os.path.isfile(java_executable_path) + log.info(f"[find_java_executable] Check 1: Does path exist as a file? {is_file}") + except Exception as e_isfile: + log.error(f"[find_java_executable] Error checking if path is file: {e_isfile}") + return None + + if is_file: + # --- Check 2: Execute Permission (Use synchronous os.access) --- + try: + is_executable = os.access(java_executable_path, os.X_OK) + log.info(f"[find_java_executable] Check 2: Is file executable (os.X_OK)? {is_executable}") + if is_executable: + log.info(f"[find_java_executable] Success! Found accessible executable: {java_executable_path.resolve()}") + return java_executable_path.resolve() + else: + log.warning(f"[find_java_executable] File found but not executable: {java_executable_path}") + return None + except Exception as e_access: + log.error(f"[find_java_executable] Error checking execute permission with os.access: {e_access}") + return None + else: + log.warning(f"[find_java_executable] Executable path not found or is not a file.") + # Fallback logic (only runs if potential_sub_dir was found but failed the check above) + if potential_sub_dir and base_dir_to_check == potential_sub_dir: + log.info(f"[find_java_executable] Retrying search directly in base directory: {extract_dir}") + fallback_path = None + if system == 'Windows': + fallback_path = extract_dir / 'bin' / 'java.exe' + elif system == 'Darwin': + fallback_path = extract_dir / 'Contents' / 'Home' / 'bin' / 'java' + else: + fallback_path = extract_dir / 'bin' / 'java' + + if fallback_path: + log.info(f"[find_java_executable] Checking fallback path: {fallback_path}") + try: + # Use await for async file check + fb_is_file = await aiofiles.os.path.isfile(fallback_path) + log.info(f"[find_java_executable] Fallback Check 1: Exists as file? {fb_is_file}") + if fb_is_file: + # Use sync permission check + fb_is_executable = os.access(fallback_path, os.X_OK) + log.info(f"[find_java_executable] Fallback Check 2: Is executable? {fb_is_executable}") + if fb_is_executable: + log.info(f"[find_java_executable] Success on fallback! Found: {fallback_path.resolve()}") + return fallback_path.resolve() + else: + log.warning(f"[find_java_executable] Fallback file found but not executable.") + else: + log.warning(f"[find_java_executable] Fallback path not found or not a file.") + except Exception as e_fb: + log.error(f"[find_java_executable] Error during fallback check: {e_fb}") + + log.warning(f"[find_java_executable] Could not find executable via primary or fallback paths.") + return None + + except Exception as e: + log.exception(f"[find_java_executable] Unexpected error searching in {extract_dir}: {e}") # Use log.exception to get traceback + return None + + +# Function to run synchronous extraction in a separate thread +def _extract_zip(zip_data: bytes, dest_path: pathlib.Path): + import io + with io.BytesIO(zip_data) as zip_buffer: + with zipfile.ZipFile(zip_buffer, 'r') as zip_ref: + zip_ref.extractall(dest_path) + +def _extract_tar(tar_path: pathlib.Path, dest_path: pathlib.Path): + # Check if the tar file exists before trying to open it + if not tar_path.is_file(): + log.error(f"Tar file not found for extraction: {tar_path}") + raise FileNotFoundError(f"Tar file not found: {tar_path}") + try: + with tarfile.open(tar_path, "r:gz") as tar_ref: + # tarfile doesn't have a built-in strip_components like the command line + # We need to manually filter members or extract carefully + # For simplicity here, we assume strip=1 behaviour is desired and + # hope the find_java_executable handles the structure. + # A more robust solution would iterate members and adjust paths. + tar_ref.extractall(path=dest_path) # This might create a top-level dir + except tarfile.ReadError as e: + log.error(f"Error reading tar file {tar_path}: {e}") + raise + except Exception as e: + log.error(f"Unexpected error during tar extraction from {tar_path}: {e}") + raise + +# --- Main Exported Function --- +async def download_java( + version: int = DEFAULT_JAVA_VERSION, + destination_dir_str: str | None = None, + image_type: str = DEFAULT_IMAGE_TYPE, + vendor: str = 'eclipse', + jvm_impl: str = 'hotspot', +) -> str | None: + """ + Downloads and extracts a standalone Java runtime/JDK if not already present. + + Args: + version: The major Java version (e.g., 11, 17, 21). + destination_dir_str: Directory for Java. If None, a temporary dir is used. + **Crucially, if this directory already contains a valid executable, download will be skipped.** + image_type: Type of Java package ('jdk' or 'jre'). + vendor: The build vendor (usually 'eclipse' for Temurin). + jvm_impl: The JVM implementation. + + Returns: + The absolute path to the Java executable as a string if successful, otherwise None. + """ + + if destination_dir_str is None: + # Use mkdtemp for a secure temporary directory if none provided + # Note: This temporary directory won't persist across runs. + # A fixed path is usually better for caching. + # Running sync mkdtemp in executor to avoid blocking + loop = asyncio.get_running_loop() + temp_dir_str = await loop.run_in_executor(None, tempfile.mkdtemp, f"downloaded-java-{secrets.token_hex(4)}-") + destination_dir = pathlib.Path(temp_dir_str) + log.info(f"No destination directory provided, using temporary directory: {destination_dir}") + else: + destination_dir = pathlib.Path(destination_dir_str).resolve() + + + platform_info = get_api_os_arch() + if not platform_info: + return None + api_os = platform_info["os"] + api_arch = platform_info["arch"] + current_system = platform.system() + + # --- Check if Java executable already exists --- + log.info(f"Checking for existing Java executable in: {destination_dir}") + try: + existing_java_path = await find_java_executable(destination_dir, current_system) + if existing_java_path: + log.info(f"Valid Java executable already found at: {existing_java_path}. Skipping download.") + return str(existing_java_path) + else: + log.info(f"Existing Java executable not found or installation is incomplete in {destination_dir}.") + except Exception as check_error: + # Log the exception details if the check itself fails + log.exception(f"Error during pre-check for existing Java in {destination_dir}: {check_error}. Assuming download is needed.") + # --- End Check --- + + log.info('Proceeding with Java download and extraction process...') + api_url = f"{ADOPTIUM_API_BASE}/binary/latest/{version}/ga/{api_os}/{api_arch}/{image_type}/{jvm_impl}/normal/{vendor}" + log.info(f"Attempting to download Java {version} ({image_type}) for {api_os}-{api_arch} from Adoptium API.") + + download_url = None + archive_type = None + + async with aiohttp.ClientSession() as session: + try: + log.info(f"Fetching download details (HEAD request) from: {api_url}") + # Use allow_redirects=True and get the final URL + async with session.head(api_url, allow_redirects=True) as head_response: + head_response.raise_for_status() # Raise exception for bad status codes (4xx, 5xx) + download_url = str(head_response.url) # Get the final URL after redirects + + if not download_url: + raise ValueError("Could not resolve download URL after redirects.") + + if download_url.endswith('.zip'): + archive_type = 'zip' + elif download_url.endswith('.tar.gz'): + archive_type = 'tar.gz' + else: + # Guess based on OS if extension is missing (less reliable) + archive_type = 'zip' if api_os == 'windows' else 'tar.gz' + + log.info(f"Resolved download URL: {download_url}") + log.info(f"Detected archive type: {archive_type}") + + # Ensure destination directory exists + # Use await aiofiles.os.makedirs + await aiofiles.os.makedirs(destination_dir, exist_ok=True) + log.info(f"Ensured destination directory exists: {destination_dir}") + + log.info('Starting download...') + async with session.get(download_url) as response: + response.raise_for_status() + file_data = await response.read() + log.info('Download complete.') + + log.info(f"Extracting {archive_type} archive to {destination_dir}...") + loop = asyncio.get_running_loop() + + if archive_type == 'zip': + # Run synchronous zip extraction in a thread + await loop.run_in_executor(None, _extract_zip, file_data, destination_dir) + else: # tar.gz + # Write to a temporary file first for tarfile + temp_tar_path = None + # Use a context manager for the temporary file + # Running sync tempfile operations in executor + fd, temp_tar_path_str = await loop.run_in_executor(None, tempfile.mkstemp, ".tar.gz", "java-dl-") + os.close(fd) # Close descriptor from mkstemp + temp_tar_path = pathlib.Path(temp_tar_path_str) + + try: + log.debug(f"Saving temporary tar archive to: {temp_tar_path}") + async with aiofiles.open(temp_tar_path, 'wb') as f: + await f.write(file_data) + log.debug(f"Temporary archive saved successfully.") + + # Run synchronous tar extraction in a thread + log.debug(f"Starting tar extraction from {temp_tar_path}...") + await loop.run_in_executor(None, _extract_tar, temp_tar_path, destination_dir) + log.debug('Extraction using tar complete.') + + finally: + # Clean up temporary tar file using await aiofiles.os.remove + if temp_tar_path and await aiofiles.os.path.exists(temp_tar_path): + try: + await aiofiles.os.remove(temp_tar_path) + log.debug(f"Temporary file {temp_tar_path} deleted.") + except OSError as e: + log.warning(f"Could not delete temporary tar file {temp_tar_path}: {e}") + elif temp_tar_path: + log.debug(f"Temporary file {temp_tar_path} did not exist for deletion.") + + log.info('Extraction complete.') + + # --- Find executable AFTER extraction --- + # Add a small delay, just in case of filesystem flush issues (optional) + # await asyncio.sleep(0.5) + log.info("Re-checking for Java executable after extraction...") + java_path = await find_java_executable(destination_dir, current_system) + + if java_path: + log.info(f"Java executable successfully found after extraction at: {java_path}") + return str(java_path) + else: + log.error('Extraction seemed successful, but failed to find Java executable at the expected location afterwards.') + log.error(f"Please double-check the contents of {destination_dir} and the logic in find_java_executable for platform {current_system}.") + # Log directory contents for debugging + try: + log.error(f"Contents of {destination_dir}: {os.listdir(destination_dir)}") + # Check potential subdir contents too + for item in os.listdir(destination_dir): + item_path = destination_dir / item + if item_path.is_dir(): + log.error(f"Contents of {item_path}: {os.listdir(item_path)}") + bin_path = item_path / 'bin' + if bin_path.is_dir(): + log.error(f"Contents of {bin_path}: {os.listdir(bin_path)}") + break # Show first subdir found + except Exception as list_err: + log.error(f"Could not list directory contents for debugging: {list_err}") + return None + + except aiohttp.ClientResponseError as e: + log.error(f"HTTP Error downloading Java: {e.status} {e.message}") + if e.status == 404: + log.error(f"Could not find a build for Java {version} ({image_type}) for {api_os}-{api_arch}. Check Adoptium website for availability.") + else: + log.error(f"Response Headers: {e.headers}") + # Try reading response body if available (might be large) + try: + error_body = await e.response.text() + log.error(f"Response Body (partial): {error_body[:500]}") + except Exception: + pass # Ignore if body can't be read + + log.error(f"Java download/extraction failed. Directory {destination_dir} may be incomplete.") + return None + except Exception as error: + log.exception(f"An unexpected error occurred during download/extraction: {error}") + log.error(f"Java download/extraction failed. Directory {destination_dir} may be incomplete.") + return None + +# Example usage (optional, can be run with `python java.py`) +if __name__ == "__main__": + async def run_test(): + print("Testing Java Downloader...") + # Define a test destination directory + test_dest = pathlib.Path("./test-java-runtime").resolve() + print(f"Will attempt to download Java to: {test_dest}") + + # Clean up previous test run if exists + if test_dest.exists(): + import shutil + print("Removing previous test directory...") + shutil.rmtree(test_dest) + + java_exe_path = await download_java( + version=21, # Specify version + destination_dir_str=str(test_dest), + image_type='jdk' + ) + + if java_exe_path: + print(f"\nSuccess! Java executable path: {java_exe_path}") + # Try running java -version + try: + print("\nRunning java -version:") + proc = await asyncio.create_subprocess_exec( + java_exe_path, + "-version", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + # java -version often prints to stderr + print("Exit Code:", proc.returncode) + if stderr: + print("Output (stderr):\n", stderr.decode()) + if stdout: # Just in case it prints to stdout + print("Output (stdout):\n", stdout.decode()) + + except Exception as e: + print(f"Error running java -version: {e}") + else: + print("\nFailed to download or find Java executable.") + + asyncio.run(run_test()) \ No newline at end of file diff --git a/replacer.py b/replacer.py new file mode 100644 index 0000000..16a53f0 --- /dev/null +++ b/replacer.py @@ -0,0 +1,68 @@ +import logging + +# Configure logging (optional, but good practice) +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +log = logging.getLogger(__name__) + +def replace_text(value: str, replacements: dict) -> str: + """ + Replaces all occurrences of specified substrings within a string. + Does not use regular expressions. + + Args: + value: The original string to perform replacements on. + replacements: A dictionary where keys are the substrings + to find and values are the strings to + replace them with. + + Returns: + The string with all specified replacements made. + Returns the original value if it's not a string + or if replacements is not a valid dictionary. + """ + if not isinstance(value, str): + log.warning("replace_text: Input 'value' is not a string. Returning original value.") + return value + + if not isinstance(replacements, dict): + log.warning("replace_text: Input 'replacements' is not a valid dictionary. Returning original value.") + return value + + modified_value = value + + # Iterate through each key-value pair in the replacements dictionary + for search_string, replace_string in replacements.items(): + # Ensure both search and replace values are strings for safety + if isinstance(search_string, str) and isinstance(replace_string, str): + # Use str.replace() to replace all occurrences + modified_value = modified_value.replace(search_string, replace_string) + else: + log.warning(f"replace_text: Skipping replacement for key '{search_string}' as either key or value is not a string.") + + return modified_value + +# Example Usage (matches the JS comment example) +# if __name__ == "__main__": +# import os +# __dirname = os.path.abspath('.') # Simulate __dirname for example + +# original_config = { +# "executable": ":thisdir:/bin/launcher", +# "configFile": "/etc/config.conf", +# "logPath": ":thisdir:/logs/app.log", +# "tempDir": "/tmp", +# "description": "Uses :thisdir: multiple :thisdir: times." +# } + +# replacements = {":thisdir:": __dirname} + +# patched_config = {} +# for key, value in original_config.items(): +# patched_config[key] = replace_text(value, replacements) # Note: Not async in Python + +# print("Original Config:", original_config) +# print("Patched Config:", patched_config) + +# # Expected Output (assuming __dirname = '/path/to/current/directory'): +# # Original Config: {'executable': ':thisdir:/bin/launcher', 'configFile': '/etc/config.conf', 'logPath': ':thisdir:/logs/app.log', 'tempDir': '/tmp', 'description': 'Uses :thisdir: multiple :thisdir: times.'} +# # Patched Config: {'executable': '/path/to/current/directory/bin/launcher', 'configFile': '/etc/config.conf', 'logPath': '/path/to/current/directory/logs/app.log', 'tempDir': '/tmp', 'description': 'Uses /path/to/current/directory multiple /path/to/current/directory times.'} \ No newline at end of file From 3e45d4685e97eb8b95f731b6f323e9b593eb3817 Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Tue, 6 May 2025 12:08:02 +0200 Subject: [PATCH 03/19] Remove python port --- __main__.py | 1002 --------------------------------------------------- java.py | 428 ---------------------- replacer.py | 68 ---- 3 files changed, 1498 deletions(-) delete mode 100644 __main__.py delete mode 100644 java.py delete mode 100644 replacer.py diff --git a/__main__.py b/__main__.py deleted file mode 100644 index f8fc65b..0000000 --- a/__main__.py +++ /dev/null @@ -1,1002 +0,0 @@ -# main.py -import os -import platform -import pathlib -import json -import hashlib -import asyncio -import logging -import sys -import zipfile -import shutil # For copytree and rmtree -from typing import Dict, Any, List, Optional, Union - -import aiohttp -import aiofiles -import aiofiles.os -from tqdm.asyncio import tqdm # Use tqdm's async version - -# --- Local Imports --- -try: - # Assuming java.py and replacer.py are in the same directory - from java import download_java - from replacer import replace_text -except ImportError as e: - print(f"Error importing local modules (java.py, replacer.py): {e}") - print("Please ensure these files are in the same directory as main.py.") - sys.exit(1) - -# --- Logging Setup --- -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -log = logging.getLogger(__name__) - -# --- Constants and Configuration --- -DEFAULT_VERSION_MANIFEST = 'neoforge-21.1.162.json' # Default if not in launcher_config -SCRIPT_DIR = pathlib.Path(__file__).parent.resolve() - -# Load launcher_config.json and patch paths -try: - with open(SCRIPT_DIR / "launcher_config.json", 'r') as f: - launcher_config_raw = json.load(f) -except FileNotFoundError: - log.error("launcher_config.json not found in the script directory.") - sys.exit(1) -except json.JSONDecodeError as e: - log.error(f"Error parsing launcher_config.json: {e}") - sys.exit(1) - -# Patch launcher config immediately (replace_text is synchronous) -launcher_config = {} -for key, value in launcher_config_raw.items(): - launcher_config[key] = replace_text(value, {':thisdir:': str(SCRIPT_DIR)}) - -log.info(f"Launcher config: {json.dumps(launcher_config, indent=2)}") - -# Main target version manifest filename from launcher_config or default -TARGET_VERSION_MANIFEST_FILENAME = launcher_config.get('version', DEFAULT_VERSION_MANIFEST) - -# Load user config (config.json) if it exists -cfg = {} -try: - config_path = SCRIPT_DIR / 'config.json' - if config_path.exists(): - # Reading small config at start is often acceptable synchronously. - with open(config_path, 'r', encoding='utf-8') as f: - cfg = json.load(f) -except json.JSONDecodeError as e: - log.warning(f"Could not parse config.json: {e}. Using defaults.") -except Exception as e: - log.warning(f"Could not read config.json: {e}. Using defaults.") - -# --- Authentication Details --- -AUTH_PLAYER_NAME = cfg.get("auth_player_name") if cfg.get("auth_player_name") else 'Player' -AUTH_UUID = cfg.get("auth_uuid") if cfg.get("auth_uuid") else '00000000-0000-0000-0000-000000000000' -AUTH_ACCESS_TOKEN = cfg.get("auth_access_token") if cfg.get("auth_access_token") else '00000000000000000000000000000000' -AUTH_XUID = cfg.get("auth_xuid") if cfg.get("auth_xuid") else '0' -USER_TYPE = 'msa' - -# --- Directories --- -BASE_PATH = pathlib.Path(launcher_config.get('basepath', SCRIPT_DIR / '.mc_launcher_data')) -MINECRAFT_DIR = BASE_PATH / launcher_config.get('path', '.minecraft') -VERSIONS_DIR = MINECRAFT_DIR / 'versions' -LIBRARIES_DIR = MINECRAFT_DIR / 'libraries' -ASSETS_DIR = MINECRAFT_DIR / 'assets' -ASSET_INDEXES_DIR = ASSETS_DIR / 'indexes' -ASSET_OBJECTS_DIR = ASSETS_DIR / 'objects' -LAUNCHER_PROFILES_PATH = MINECRAFT_DIR / 'launcher_profiles.json' -CLIENT_STORAGE_PATH = MINECRAFT_DIR / 'client_storage.json' -BACKUP_PATH_BASE = SCRIPT_DIR / '.minecraft_autobackup' -JAVA_INSTALL_DIR = SCRIPT_DIR / 'java-runtime' - -# --- Helper Functions --- - -async def get_file_sha1(file_path: pathlib.Path) -> str: - """Calculates the SHA1 hash of a file asynchronously.""" - sha1_hash = hashlib.sha1() - try: - async with aiofiles.open(file_path, 'rb') as f: - while True: - chunk = await f.read(8192) - if not chunk: - break - sha1_hash.update(chunk) - return sha1_hash.hexdigest() - except FileNotFoundError: - raise FileNotFoundError(f"File not found for SHA1 calculation: {file_path}") - except Exception as e: - raise RuntimeError(f"Error calculating SHA1 for {file_path}: {e}") - -async def file_exists(file_path: pathlib.Path) -> bool: - """Checks if a file exists asynchronously.""" - try: - stats = await aiofiles.os.stat(file_path) - # Check if it's a regular file using stat results - return stats.st_mode & 0o100000 != 0 - except OSError: # Includes FileNotFoundError - return False - -# Shared aiohttp session -AIOHTTP_SESSION = None - -async def get_session(): - global AIOHTTP_SESSION - if AIOHTTP_SESSION is None or AIOHTTP_SESSION.closed: - AIOHTTP_SESSION = aiohttp.ClientSession() - return AIOHTTP_SESSION - -async def close_session(): - global AIOHTTP_SESSION - if AIOHTTP_SESSION and not AIOHTTP_SESSION.closed: - await AIOHTTP_SESSION.close() - AIOHTTP_SESSION = None - -async def download_file( - url: str, - dest_path: pathlib.Path, - expected_sha1: Optional[str], - force_download: bool = False, - pbar: Optional[tqdm] = None # Progress bar instance passed in -) -> bool: - """Downloads a file asynchronously, verifies SHA1 hash.""" - dir_path = dest_path.parent - await aiofiles.os.makedirs(dir_path, exist_ok=True) - - needs_download = force_download - exists = await file_exists(dest_path) - - if exists and not force_download: - if expected_sha1: - try: - current_sha1 = await get_file_sha1(dest_path) - if current_sha1.lower() == expected_sha1.lower(): - if pbar: pbar.update(1) # Update progress even if skipped - return False # No download needed - else: - log.warning(f"SHA1 mismatch for existing file {dest_path.name}. Expected {expected_sha1}, got {current_sha1}. Redownloading.") - needs_download = True - except Exception as hash_error: - log.warning(f"Warning: Could not hash existing file {dest_path}. Redownloading. Error: {hash_error}") - needs_download = True - else: - if pbar: pbar.update(1) - return False # File exists, no hash check, no download needed - elif not exists: - needs_download = True - - if not needs_download: - if pbar: pbar.update(1) # Ensure progress updates if no download needed - return False - - session = await get_session() - try: - async with session.get(url) as response: - if not response.ok: - # Clean up potentially corrupted file before throwing - try: - if await aiofiles.os.path.exists(dest_path): await aiofiles.os.remove(dest_path) - except OSError: pass # Ignore deletion errors - raise aiohttp.ClientResponseError( - response.request_info, response.history, - status=response.status, message=f"Failed to download {url}: {response.reason}", headers=response.headers - ) - # Stream download to file - async with aiofiles.open(dest_path, 'wb') as f: - async for chunk in response.content.iter_chunked(8192): - await f.write(chunk) - - if expected_sha1: - downloaded_sha1 = await get_file_sha1(dest_path) - if downloaded_sha1.lower() != expected_sha1.lower(): - raise ValueError(f"SHA1 mismatch for {dest_path.name}. Expected {expected_sha1}, got {downloaded_sha1}") - - if pbar: pbar.update(1) # Update progress after successful download - return True # Download occurred - - except Exception as error: - log.error(f"Error downloading {url}: {error}") - # Clean up potentially incomplete file - try: - if await aiofiles.os.path.exists(dest_path): await aiofiles.os.remove(dest_path) - except OSError: pass - if pbar: pbar.close() # Stop progress bar on error - raise # Re-throw to stop the process - -# Sync zip extraction (run in executor) -def _extract_zip_sync(jar_path: pathlib.Path, extract_to_dir: pathlib.Path): - try: - with zipfile.ZipFile(jar_path, 'r') as zip_ref: - for member in zip_ref.infolist(): - # Skip directories and META-INF - if not member.is_dir() and not member.filename.upper().startswith('META-INF/'): - try: - # Preserve directory structure within the zip - zip_ref.extract(member, extract_to_dir) - except Exception as extract_error: - log.warning(f"Warning: Could not extract {member.filename} from {jar_path.name}. Error: {extract_error}") - except zipfile.BadZipFile: - log.error(f"Failed to read zip file (BadZipFile): {jar_path}") - raise - except Exception as e: - log.error(f"Failed to process zip file {jar_path}: {e}") - raise - -async def extract_natives(jar_path: pathlib.Path, extract_to_dir: pathlib.Path): - """Extracts native libraries from a JAR file asynchronously.""" - await aiofiles.os.makedirs(extract_to_dir, exist_ok=True) - loop = asyncio.get_running_loop() - # Run the synchronous extraction function in a thread pool executor - await loop.run_in_executor(None, _extract_zip_sync, jar_path, extract_to_dir) - - -def get_os_name() -> str: - """Gets the current OS name ('windows', 'osx', 'linux').""" - system = platform.system() - if system == 'Windows': return 'windows' - elif system == 'Darwin': return 'osx' - elif system == 'Linux': return 'linux' - else: raise OSError(f"Unsupported platform: {system}") - -def get_arch_name() -> str: - """Gets the current architecture name ('x64', 'x86', 'arm64', 'arm32').""" - machine = platform.machine().lower() - if machine in ['amd64', 'x86_64']: return 'x64' - elif machine in ['i386', 'i686']: return 'x86' - elif machine in ['arm64', 'aarch64']: return 'arm64' - elif machine.startswith('arm') and '64' not in machine: return 'arm32' - else: - # Fallback or error for less common architectures - log.warning(f"Unsupported architecture: {platform.machine()}. Falling back to 'x64'. This might cause issues.") - return 'x64' - - -# --- Rule Processing --- - -def check_rule(rule: Optional[Dict[str, Any]]) -> bool: - """ - Checks if a *single rule* permits an item based on the current environment. - Returns True if the rule permits inclusion, False otherwise. - """ - if not rule or 'action' not in rule: - return True # Default allow if no rule/action specified - - action = rule.get('action', 'allow') - applies = True # Assume the condition matches unless proven otherwise - - # Check OS condition - if 'os' in rule and isinstance(rule['os'], dict): - os_rule = rule['os'] - current_os = get_os_name() - current_arch = get_arch_name() - if 'name' in os_rule and os_rule['name'] != current_os: - applies = False - if applies and 'arch' in os_rule and os_rule['arch'] != current_arch: - applies = False - # Version check omitted for simplicity - - # Check features condition - if applies and 'features' in rule and isinstance(rule['features'], dict): - features_rule = rule['features'] - # Get feature flags from config or use defaults - is_demo = cfg.get('demo', False) - has_custom_res = True # Assume true like JS example, maybe get from cfg? - - # Check specific features mentioned in the rule. - # If *any* specified feature condition is NOT met, 'applies' becomes False. - if 'is_demo_user' in features_rule: - if features_rule['is_demo_user'] != is_demo: - applies = False - if applies and 'has_custom_resolution' in features_rule: - if features_rule['has_custom_resolution'] != has_custom_res: - applies = False - # Add other feature checks here based on rule.features keys similarly... - - # Evaluate the rule's final outcome based on action and condition match - if action == 'allow': - # Allow action: Item permitted only if conditions apply - return applies - elif action == 'disallow': - # Disallow action: Item permitted only if conditions *do not* apply - return not applies - else: - log.warning(f"Unknown rule action: {action}. Defaulting to allow.") - return True - -def check_item_rules(rules: Optional[List[Dict[str, Any]]]) -> bool: - """ - Checks if an item (library/argument) should be included based on its rules array. - Implements the logic: Disallow if *any* rule prevents inclusion. - """ - if not rules: - return True # No rules, always include (default allow) - - # Assume allowed unless a rule forbids it - allowed = True - for rule in rules: - # check_rule returns True if this specific rule *permits* inclusion - if not check_rule(rule): - # If check_rule is False, this rule prevents inclusion - allowed = False - # log.debug(f"Item disallowed by rule: {rule}") # Optional debug - break # No need to check further rules, it's disallowed - - return allowed - -# --- End Rule Processing --- - - -async def ensure_launcher_profiles(current_version_id: str): - """Ensures launcher_profiles.json exists and contains basic info.""" - log.info('Checking launcher profiles...') - profile_name = f"custom-{current_version_id}" - # Generate profile key from UUID (consistent with vanilla launcher) - auth_profile_key = AUTH_UUID.replace('-', '') - account_key = f"account-{auth_profile_key}" # Used in newer profile formats - - # Basic structure - profiles_data = { - "profiles": { - profile_name: { - # "created": datetime.now().isoformat(), # Optional - # "icon": "Furnace", # Optional - "lastUsed": "1970-01-01T00:00:00.000Z", # Needs update on launch - "lastVersionId": current_version_id, - "name": profile_name, - "type": "custom" - # "javaArgs": "-Xmx2G", # Optional - # "gameDir": str(MINECRAFT_DIR) # Optional - } - }, - "authenticationDatabase": { - account_key: { - "accessToken": AUTH_ACCESS_TOKEN, - "profiles": { - auth_profile_key: { - "displayName": AUTH_PLAYER_NAME, - "playerUUID": AUTH_UUID, - "userId": AUTH_XUID, - # "texture": "..." # Optional base64 skin/cape - } - }, - "username": AUTH_PLAYER_NAME, # Often email for MSA - "properties": [], # Optional user properties - # "remoteId": "...", # Xbox user ID - } - }, - "settings": { - # "locale": "en-us", - # "showMenu": True, - # ... other launcher settings - }, - "selectedUser": { - "account": account_key, - "profile": auth_profile_key - }, - "version": 4 # Common version number for this format - } - try: - # Ensure directory exists first - await aiofiles.os.makedirs(LAUNCHER_PROFILES_PATH.parent, exist_ok=True) - # Write the file asynchronously - async with aiofiles.open(LAUNCHER_PROFILES_PATH, 'w', encoding='utf-8') as f: - await f.write(json.dumps(profiles_data, indent=2)) - log.info(f"Created/updated {LAUNCHER_PROFILES_PATH}") - except Exception as error: - log.error(f"Failed to write {LAUNCHER_PROFILES_PATH}: {error}") - raise RuntimeError("Could not write launcher profiles file.") - - -async def load_manifest(filename: str) -> Dict[str, Any]: - """Loads a JSON manifest file from the script's directory asynchronously.""" - file_path = SCRIPT_DIR / filename - log.info(f"Loading manifest: {filename}") - try: - async with aiofiles.open(file_path, 'r', encoding='utf-8') as f: - content = await f.read() - return json.loads(content) - except FileNotFoundError: - log.error(f"Manifest file not found: {file_path}") - raise - except json.JSONDecodeError as e: - log.error(f"Failed to parse manifest {filename}: {e}") - raise ValueError(f"Invalid JSON in manifest: {filename}") - except Exception as e: - log.error(f"Failed to load manifest {filename}: {e}") - raise - -def merge_manifests(target_manifest: Dict[str, Any], base_manifest: Dict[str, Any]) -> Dict[str, Any]: - """Merges two version manifests (target inheriting from base).""" - target_id = target_manifest.get('id', 'unknown-target') - base_id = base_manifest.get('id', 'unknown-base') - log.info(f"Merging manifests: {target_id} inheriting from {base_id}") - - # Combine libraries: Use a dictionary keyed by 'name' for overrides. - combined_libraries_map = {} - for lib in base_manifest.get('libraries', []): - if 'name' in lib: combined_libraries_map[lib['name']] = lib - for lib in target_manifest.get('libraries', []): - if 'name' in lib: combined_libraries_map[lib['name']] = lib # Overwrite - - # Combine arguments: Append target args to base args. - base_args = base_manifest.get('arguments', {}) or {} - target_args = target_manifest.get('arguments', {}) or {} - combined_arguments = { - "game": (base_args.get('game', []) or []) + (target_args.get('game', []) or []), - "jvm": (base_args.get('jvm', []) or []) + (target_args.get('jvm', []) or []) - } - - # Construct merged manifest, prioritizing target values. - merged = { - "id": target_manifest.get('id'), - "time": target_manifest.get('time'), - "releaseTime": target_manifest.get('releaseTime'), - "type": target_manifest.get('type'), - "mainClass": target_manifest.get('mainClass'), # Target overrides - "assetIndex": target_manifest.get('assetIndex', base_manifest.get('assetIndex')), # Prefer target - "assets": target_manifest.get('assets', base_manifest.get('assets')), # Asset ID string - "downloads": base_manifest.get('downloads'), # Use base downloads (client.jar etc.) - "javaVersion": target_manifest.get('javaVersion', base_manifest.get('javaVersion')), # Prefer target - "libraries": list(combined_libraries_map.values()), # Convert dict values back to list - "arguments": combined_arguments, - "logging": target_manifest.get('logging', base_manifest.get('logging')), # Prefer target - "complianceLevel": target_manifest.get('complianceLevel', base_manifest.get('complianceLevel')), - "minimumLauncherVersion": target_manifest.get('minimumLauncherVersion', base_manifest.get('minimumLauncherVersion')) - } - # Clean up None values from .get() fallbacks - return {k: v for k, v in merged.items() if v is not None} - -# Sync backup function (to be run in executor) -def _create_backup_sync(source_dir: pathlib.Path, backup_dir_base: pathlib.Path): - """Copies source to backup dir, zips it, then removes backup dir.""" - backup_dir = backup_dir_base # Directory to copy into first - backup_zip_path = backup_dir_base.with_suffix('.zip') - - log.info(f"Starting backup copy from {source_dir} to {backup_dir}") - # Remove old backup directory/zip if they exist - if backup_zip_path.exists(): - log.debug(f"Removing existing backup zip: {backup_zip_path}") - backup_zip_path.unlink() - if backup_dir.exists(): - log.debug(f"Removing existing intermediate backup dir: {backup_dir}") - shutil.rmtree(backup_dir) - - # Copy the entire directory - shutil.copytree(source_dir, backup_dir, dirs_exist_ok=True) - log.info(f"Backup copy complete. Zipping {backup_dir} to {backup_zip_path}") - - # Zip the backup directory - with zipfile.ZipFile(backup_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: - for root, _, files in os.walk(backup_dir): - for file in files: - file_path = pathlib.Path(root) / file - # Arcname is the path inside the zip file relative to backup_dir - arcname = file_path.relative_to(backup_dir) - zipf.write(file_path, arcname) - log.info(f"Backup zip created at {backup_zip_path}") - - # Remove the temporary backup directory after zipping - shutil.rmtree(backup_dir) - log.info(f"Removed intermediate backup directory {backup_dir}") - -# --- Main Execution --- -async def main(): - try: - # 1. Load Target Manifest - target_manifest = await load_manifest(TARGET_VERSION_MANIFEST_FILENAME) - target_version_id = target_manifest.get('id') - if not target_version_id: - raise ValueError(f"Target manifest {TARGET_VERSION_MANIFEST_FILENAME} is missing required 'id' field.") - - # 2. Load Base Manifest if needed and Merge - final_manifest = target_manifest - if 'inheritsFrom' in target_manifest: - base_version_id = target_manifest['inheritsFrom'] - base_manifest_filename = f"{base_version_id}.json" - try: - base_manifest = await load_manifest(base_manifest_filename) - final_manifest = merge_manifests(target_manifest, base_manifest) - except (FileNotFoundError, ValueError, KeyError, Exception) as e: - log.error(f"Could not load or merge base manifest '{base_manifest_filename}' specified in {TARGET_VERSION_MANIFEST_FILENAME}: {e}") - sys.exit(1) - else: - log.info(f"Manifest {target_version_id} does not inherit from another version.") - - # --- Use finalManifest for all subsequent steps --- - version_id = final_manifest.get('id') # Should be the target ID after merge - if not version_id: - raise ValueError("Final merged manifest is missing the 'id' field.") - - version_dir = VERSIONS_DIR / version_id - natives_dir = version_dir / f"{version_id}-natives" - - log.info(f"Preparing Minecraft {version_id}...") - os_name = get_os_name() - arch_name = get_arch_name() - log.info(f"Detected OS: {os_name}, Arch: {arch_name}") - - # 4. Ensure Directories and Launcher Profiles - log.info(f"Ensuring base directory exists: {MINECRAFT_DIR}") - # Create all necessary directories asynchronously and concurrently - await asyncio.gather( - aiofiles.os.makedirs(MINECRAFT_DIR, exist_ok=True), - aiofiles.os.makedirs(VERSIONS_DIR, exist_ok=True), - aiofiles.os.makedirs(LIBRARIES_DIR, exist_ok=True), - aiofiles.os.makedirs(ASSETS_DIR, exist_ok=True), - aiofiles.os.makedirs(version_dir, exist_ok=True), - aiofiles.os.makedirs(natives_dir, exist_ok=True), - aiofiles.os.makedirs(ASSET_INDEXES_DIR, exist_ok=True), - aiofiles.os.makedirs(ASSET_OBJECTS_DIR, exist_ok=True) - ) - # Create/update launcher profiles - await ensure_launcher_profiles(version_id) - - # 5. Copy *Target* Version Manifest JSON to Version Directory - target_manifest_source_path = SCRIPT_DIR / TARGET_VERSION_MANIFEST_FILENAME - dest_manifest_path = version_dir / f"{version_id}.json" - try: - log.info(f"Copying {target_manifest_source_path.name} to {dest_manifest_path}") - # Run synchronous copy in executor to avoid blocking - await asyncio.get_running_loop().run_in_executor( - None, shutil.copyfile, target_manifest_source_path, dest_manifest_path - ) - except Exception as error: - log.error(f"Failed to copy target version manifest: {error}") - raise RuntimeError(f"Could not copy version manifest file: {target_manifest_source_path}") - - # 6. Download Client JAR - log.info('Checking client JAR...') - client_info = final_manifest.get('downloads', {}).get('client') - if not (client_info and 'url' in client_info and 'sha1' in client_info): - raise ValueError(f"Merged manifest for {version_id} is missing client download information (url, sha1).") - client_jar_path = version_dir / f"{version_id}.jar" - # Use tqdm context for the single download (async with not supported, manual create/close) - client_pbar = tqdm(total=1, desc="Client JAR", unit="file", leave=False) - try: - await download_file(client_info['url'], client_jar_path, client_info['sha1'], pbar=client_pbar) - finally: - client_pbar.close() - - - # 7. Prepare Library List - log.info('Processing library list...') - libraries_to_process = [] - classpath_entries_set = {str(client_jar_path)} # Use a set to avoid duplicates - native_library_paths = [] - - for lib in final_manifest.get('libraries', []): - # Check rules for the entire library entry FIRST - if not check_item_rules(lib.get('rules')): - # log.debug(f"Skipping library due to overall rules: {lib.get('name', 'N/A')}") - continue - - lib_name = lib.get('name', 'unknown-library') - downloads = lib.get('downloads', {}) - artifact = downloads.get('artifact') - classifiers = downloads.get('classifiers', {}) - natives_rules = lib.get('natives', {}) # Legacy natives mapping - - # --- Determine Native Classifier --- - native_classifier_key = None - native_info = None - # Check 'natives' mapping first (less common now) - if os_name in natives_rules: - raw_classifier = natives_rules[os_name] - # Replace ${arch} - Python needs specific replacement logic - arch_replace = '64' if arch_name == 'x64' else ('32' if arch_name == 'x86' else arch_name) - potential_key = raw_classifier.replace('${arch}', arch_replace) - if potential_key in classifiers: - native_classifier_key = potential_key - native_info = classifiers[native_classifier_key] - # log.debug(f"Found native classifier via 'natives' rule: {native_classifier_key}") - - # Check standard 'classifiers' if not found via 'natives' - if not native_info and classifiers: - # Construct potential keys based on current OS/Arch - potential_keys = [ - f"natives-{os_name}-{arch_name}", - f"natives-{os_name}", - ] - for key in potential_keys: - if key in classifiers: - native_classifier_key = key - native_info = classifiers[key] - # log.debug(f"Found native classifier via standard key: {key}") - break - - # --- Add Main Artifact --- - if artifact and artifact.get('path') and artifact.get('url'): - # Rules specific to the artifact itself are not standard in manifests. - # We rely on the top-level library rules check done earlier. - lib_path = LIBRARIES_DIR / artifact['path'] - libraries_to_process.append({ - "name": lib_name, - "url": artifact['url'], - "path": lib_path, - "sha1": artifact.get('sha1'), - "is_native": False, - }) - classpath_entries_set.add(str(lib_path)) # Add non-native to classpath - - # --- Add Native Artifact --- - if native_info and native_info.get('path') and native_info.get('url'): - # Again, rely on top-level library rules. Classifier-specific rules aren't standard. - native_path = LIBRARIES_DIR / native_info['path'] - libraries_to_process.append({ - "name": f"{lib_name}:{native_classifier_key}", # Include classifier in name for clarity - "url": native_info['url'], - "path": native_path, - "sha1": native_info.get('sha1'), - "is_native": True, - }) - native_library_paths.append(native_path) # Keep track of native JARs for extraction - - # Download Libraries (Corrected tqdm usage) - log.info(f"Downloading {len(libraries_to_process)} library files...") - lib_pbar = tqdm(total=len(libraries_to_process), desc="Libraries", unit="file", leave=False) - try: - download_tasks = [ - download_file(lib_info['url'], lib_info['path'], lib_info['sha1'], pbar=lib_pbar) - for lib_info in libraries_to_process - ] - await asyncio.gather(*download_tasks) - finally: - lib_pbar.close() - log.info('Library download check complete.') - - classpath_entries = list(classpath_entries_set) # Convert classpath set back to list - - # 8. Extract Natives - log.info('Extracting native libraries...') - # Clear existing natives directory first - try: - if await aiofiles.os.path.isdir(natives_dir): - log.debug(f"Removing existing natives directory: {natives_dir}") - # Run synchronous rmtree in executor - await asyncio.get_running_loop().run_in_executor(None, shutil.rmtree, natives_dir) - await aiofiles.os.makedirs(natives_dir, exist_ok=True) - except Exception as err: - log.warning(f"Could not clear/recreate natives directory {natives_dir}: {err}. Extraction might fail or use old files.") - - if native_library_paths: - # Corrected tqdm usage for natives - native_pbar = tqdm(total=len(native_library_paths), desc="Natives", unit="file", leave=False) - try: - extract_tasks = [] - for native_jar_path in native_library_paths: - # Define task within loop to capture correct native_jar_path - async def extract_task(jar_path, pbar_instance): - try: - await extract_natives(jar_path, natives_dir) - except Exception as e: - log.error(f"\nFailed to extract natives from: {jar_path.name}: {e}") - # Decide if you want to raise or just log - # raise # Uncomment to stop on first extraction error - finally: - pbar_instance.update(1) - - extract_tasks.append(extract_task(native_jar_path, native_pbar)) - await asyncio.gather(*extract_tasks) - finally: - native_pbar.close() # Ensure pbar is closed - else: - log.info("No native libraries to extract for this platform.") - log.info('Native extraction complete.') - - - # 9. Download Assets - log.info('Checking assets...') - asset_index_info = final_manifest.get('assetIndex') - if not (asset_index_info and 'id' in asset_index_info and 'url' in asset_index_info and 'sha1' in asset_index_info): - raise ValueError(f"Merged manifest for {version_id} is missing asset index information (id, url, sha1).") - - asset_index_id = asset_index_info['id'] - asset_index_filename = f"{asset_index_id}.json" - asset_index_path = ASSET_INDEXES_DIR / asset_index_filename - - # Download asset index (Corrected tqdm usage) - idx_pbar = tqdm(total=1, desc="Asset Index", unit="file", leave=False) - try: - await download_file(asset_index_info['url'], asset_index_path, asset_index_info['sha1'], pbar=idx_pbar) - finally: - idx_pbar.close() - - # Load asset index content - try: - async with aiofiles.open(asset_index_path, 'r', encoding='utf-8') as f: - asset_index_content = json.loads(await f.read()) - except Exception as e: - raise RuntimeError(f"Failed to read downloaded asset index {asset_index_path}: {e}") - - asset_objects = asset_index_content.get('objects', {}) - total_assets = len(asset_objects) - log.info(f"Checking {total_assets} asset files listed in index {asset_index_id}...") - - # Download assets (Corrected tqdm usage) - asset_pbar = tqdm(total=total_assets, desc="Assets", unit="file", leave=False) - try: - asset_download_tasks = [] - for asset_key, asset_details in asset_objects.items(): - asset_hash = asset_details.get('hash') - if not asset_hash: - log.warning(f"Asset '{asset_key}' is missing hash in index, skipping.") - asset_pbar.update(1); continue # Still count it towards progress - - hash_prefix = asset_hash[:2] - asset_subdir = ASSET_OBJECTS_DIR / hash_prefix - asset_filepath = asset_subdir / asset_hash - # Standard Minecraft asset download URL structure - asset_url = f"https://resources.download.minecraft.net/{hash_prefix}/{asset_hash}" - asset_download_tasks.append( - download_file(asset_url, asset_filepath, asset_hash, pbar=asset_pbar) - ) - await asyncio.gather(*asset_download_tasks) - finally: - asset_pbar.close() # Ensure pbar is closed - log.info('Asset check complete.') - - # 10. Download and Setup Java Runtime - java_version_info = final_manifest.get('javaVersion') - if not (java_version_info and 'majorVersion' in java_version_info): - log.warning("Manifest does not specify Java major version. Attempting default.") - required_java_major = DEFAULT_JAVA_VERSION # Use default from java.py - else: - required_java_major = java_version_info['majorVersion'] - - log.info(f"Checking/Installing Java {required_java_major}...") - java_executable = await download_java( - version=required_java_major, - destination_dir_str=str(JAVA_INSTALL_DIR) # Pass as string - # imageType='jre' # Optionally force JRE - ) - - if not java_executable: - log.error(f"Failed to obtain a suitable Java {required_java_major} executable.") - log.error(f"Ensure Java {required_java_major} is installed and accessible, or allow the script to download it to {JAVA_INSTALL_DIR}.") - sys.exit(1) - log.info(f"Using Java executable: {java_executable}") - - - # 11. Handle Client Storage (for NeoForge setup tracking) - log.info("Loading client storage...") - client_storage = {} - try: - if await aiofiles.os.path.exists(CLIENT_STORAGE_PATH): - async with aiofiles.open(CLIENT_STORAGE_PATH, 'r', encoding='utf-8') as f: - client_storage = json.loads(await f.read()) - else: - # Initialize if file doesn't exist - client_storage = {"setupNeoForge": []} # Start with empty list - except json.JSONDecodeError as e: - log.warning(f"Failed to load or parse {CLIENT_STORAGE_PATH}: {e}. Reinitializing.") - client_storage = {"setupNeoForge": []} # Reinitialize on error - except Exception as e: - log.error(f"Error handling {CLIENT_STORAGE_PATH}: {e}. Reinitializing.") - client_storage = {"setupNeoForge": []} - - # Ensure setupNeoForge exists and is a list (migration from old boolean) - if "setupNeoForge" not in client_storage or not isinstance(client_storage.get("setupNeoForge"), list): - client_storage["setupNeoForge"] = [] - - - # 12. Run NeoForge Installer if necessary - needs_neoforge_setup = False - if version_id.startswith("neoforge-") and version_id not in client_storage.get("setupNeoForge", []): - needs_neoforge_setup = True - neoforge_installer_jar = SCRIPT_DIR / 'neoinstaller.jar' - if not await aiofiles.os.path.isfile(neoforge_installer_jar): - log.warning(f"NeoForge version detected ({version_id}), but neoinstaller.jar not found at {neoforge_installer_jar}. Skipping automatic setup.") - needs_neoforge_setup = False # Cannot perform setup - - if needs_neoforge_setup: - log.info(f"Setting up NeoForge for {version_id}...") - # Command structure: java -jar neoinstaller.jar --install-client - setup_command_args = [ - java_executable, - "-jar", - str(neoforge_installer_jar), - "--install-client", - str(MINECRAFT_DIR) # Pass the .minecraft dir path - ] - - log.info(f"Running NeoForge setup command: {' '.join(setup_command_args)}") - # Run the installer process - process = await asyncio.create_subprocess_exec( - *setup_command_args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=str(SCRIPT_DIR) # Run from script dir to find installer jar easily? - ) - - stdout, stderr = await process.communicate() - - if stdout: log.info("NeoForge Installer Output:\n" + stdout.decode(errors='ignore')) - if stderr: log.error("NeoForge Installer Errors:\n" + stderr.decode(errors='ignore')) - - if process.returncode == 0: - log.info("NeoForge setup completed successfully.") - # Update client storage - client_storage["setupNeoForge"].append(version_id) # Assumes it's a list - try: - async with aiofiles.open(CLIENT_STORAGE_PATH, 'w', encoding='utf-8') as f: - await f.write(json.dumps(client_storage, indent=2)) - except Exception as e: - log.error(f"Failed to update {CLIENT_STORAGE_PATH} after NeoForge setup: {e}") - else: - log.error(f"NeoForge setup failed with exit code {process.returncode}. Minecraft might not launch correctly.") - # Decide if you want to exit here or try launching anyway - # sys.exit(1) - - - # 13. Construct Launch Command - log.info('Constructing launch command...') - classpath_separator = os.pathsep # Use ';' for Windows, ':' for Linux/macOS - classpath_string = classpath_separator.join(classpath_entries) - - # Argument Placeholder Replacements - replacements = { - '${natives_directory}': str(natives_dir), - '${library_directory}': str(LIBRARIES_DIR), - '${classpath_separator}': classpath_separator, - '${launcher_name}': 'CustomPythonLauncher', - '${launcher_version}': '1.0', - '${classpath}': classpath_string, - '${auth_player_name}': AUTH_PLAYER_NAME, - '${version_name}': version_id, - '${game_directory}': str(MINECRAFT_DIR), - '${assets_root}': str(ASSETS_DIR), - '${assets_index_name}': asset_index_id, # Use the ID from assetIndex - '${auth_uuid}': AUTH_UUID, - '${auth_access_token}': AUTH_ACCESS_TOKEN, - '${clientid}': 'N/A', # Placeholder - '${auth_xuid}': AUTH_XUID, - '${user_type}': USER_TYPE, - '${version_type}': final_manifest.get('type', 'release'), # Use manifest type - '${resolution_width}': cfg.get('resolution_width', '854'), - '${resolution_height}': cfg.get('resolution_height', '480'), - } - - def replace_placeholders(arg_template: str) -> str: - """Replaces all placeholders in a single argument string.""" - replaced_arg = arg_template - for key, value in replacements.items(): - replaced_arg = replaced_arg.replace(key, value) - return replaced_arg - - # --- Process JVM Arguments (Using corrected rule logic) --- - jvm_args = [] - for arg_entry in final_manifest.get('arguments', {}).get('jvm', []): - arg_values_to_add = [] # List to hold processed args for this entry - rules = None - process_this_entry = True - - if isinstance(arg_entry, str): - # Simple string argument, implicitly allowed (no rules) - arg_values_to_add.append(replace_placeholders(arg_entry)) - elif isinstance(arg_entry, dict): - # Argument object with potential rules - rules = arg_entry.get('rules') - # Check rules BEFORE processing value - if not check_item_rules(rules): - # log.debug(f"Skipping JVM arg object due to rules: {arg_entry.get('value', '')}") - process_this_entry = False # Skip this whole dict entry - else: - # Rules allow, now process the value(s) - value_from_dict = arg_entry.get('value') - if isinstance(value_from_dict, list): - arg_values_to_add.extend(replace_placeholders(val) for val in value_from_dict) - elif isinstance(value_from_dict, str): - arg_values_to_add.append(replace_placeholders(value_from_dict)) - else: - log.warning(f"Unsupported value type in JVM arg object: {value_from_dict}") - process_this_entry = False - else: - log.warning(f"Unsupported JVM argument format: {arg_entry}") - process_this_entry = False # Skip unknown format - - # Add processed arguments if the entry was allowed and processed - if process_this_entry: - for arg in arg_values_to_add: - # Basic quoting for -D properties with spaces - if arg.startswith("-D") and "=" in arg: - key, value = arg.split("=", 1) - if " " in value and not (value.startswith('"') and value.endswith('"')): - arg = f'{key}="{value}"' - jvm_args.append(arg) - - # --- Process Game Arguments (Using corrected rule logic) --- - game_args = [] - for arg_entry in final_manifest.get('arguments', {}).get('game', []): - arg_values_to_add = [] - rules = None - process_this_entry = True - - if isinstance(arg_entry, str): - arg_values_to_add.append(replace_placeholders(arg_entry)) - elif isinstance(arg_entry, dict): - rules = arg_entry.get('rules') - if not check_item_rules(rules): - # log.debug(f"Skipping game arg object due to rules: {arg_entry.get('value', '')}") - process_this_entry = False - else: - value_from_dict = arg_entry.get('value') - if isinstance(value_from_dict, list): - arg_values_to_add.extend(replace_placeholders(val) for val in value_from_dict) - elif isinstance(value_from_dict, str): - arg_values_to_add.append(replace_placeholders(value_from_dict)) - else: - log.warning(f"Unsupported value type in game arg object: {value_from_dict}") - process_this_entry = False - else: - log.warning(f"Unsupported game argument format: {arg_entry}") - process_this_entry = False - - if process_this_entry: - game_args.extend(arg_values_to_add) - - - # 14. Launch Minecraft - main_class = final_manifest.get('mainClass') - if not main_class: - raise ValueError("Final manifest is missing the 'mainClass' required for launch.") - - final_launch_args = [ - java_executable, - *jvm_args, - main_class, - *game_args, - ] - - log.info("Attempting to launch Minecraft...") - # Optionally log the full command for debugging, but be careful with tokens - # log.debug(f"Launch command: {' '.join(final_launch_args)}") - - # Run the Minecraft process - mc_process = await asyncio.create_subprocess_exec( - *final_launch_args, - stdout=sys.stdout, # Redirect child stdout to parent's stdout - stderr=sys.stderr, # Redirect child stderr to parent's stderr - cwd=MINECRAFT_DIR # Set the working directory to .minecraft - ) - - log.info(f"Minecraft process started (PID: {mc_process.pid}). Waiting for exit...") - - # Wait for the process to complete - return_code = await mc_process.wait() - - # 15. Post-Launch Actions (Backup) - log.info(f"Minecraft process exited with code {return_code}.") - - # Perform backup if configured - if cfg.get("backup", False): - log.info("Backup requested. Creating backup...") - try: - loop = asyncio.get_running_loop() - # Run the synchronous backup function in the executor - await loop.run_in_executor(None, _create_backup_sync, MINECRAFT_DIR, BACKUP_PATH_BASE) - log.info("Backup process completed.") - except Exception as backup_error: - log.error(f"Failed to create backup: {backup_error}", exc_info=True) - else: - log.info("Backup disabled in config.") - - except Exception as e: - log.exception("--- An error occurred during setup or launch ---") - # Optionally add more specific error handling or cleanup - sys.exit(1) - finally: - # Ensure the shared aiohttp session is closed on exit or error - await close_session() - - -# --- Script Entry Point --- -if __name__ == "__main__": - try: - asyncio.run(main()) - except KeyboardInterrupt: - log.info("Launch cancelled by user.") - # Ensure session is closed if KeyboardInterrupt happens before finally block in main - # Running close_session within a new asyncio.run context - try: - asyncio.run(close_session()) - except RuntimeError: # Can happen if loop is already closed - pass - # Note: SystemExit from sys.exit() will also terminate the script here \ No newline at end of file diff --git a/java.py b/java.py deleted file mode 100644 index 21f9349..0000000 --- a/java.py +++ /dev/null @@ -1,428 +0,0 @@ -import os -import platform -import pathlib -import hashlib -import asyncio -import logging -import zipfile -import tarfile -import tempfile -import secrets # For random hex bytes -import aiohttp -import aiofiles -import aiofiles.os - -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -log = logging.getLogger(__name__) - -# --- Configuration --- -ADOPTIUM_API_BASE = 'https://api.adoptium.net/v3' -DEFAULT_JAVA_VERSION = 17 -DEFAULT_IMAGE_TYPE = 'jdk' - -# --- Helper Functions --- - -def get_api_os_arch(): - """Maps Python platform/machine to Adoptium API values.""" - system = platform.system() - machine = platform.machine() - - api_os = None - api_arch = None - - if system == 'Windows': - api_os = 'windows' - elif system == 'Darwin': - api_os = 'mac' - elif system == 'Linux': - api_os = 'linux' - else: - log.error(f"Unsupported operating system: {system}") - return None - - machine = machine.lower() - if machine in ['amd64', 'x86_64']: - api_arch = 'x64' - elif machine in ['arm64', 'aarch64']: - api_arch = 'aarch64' - # Add other mappings if needed (e.g., x86, arm32) - # elif machine in ['i386', 'i686']: - # api_arch = 'x86' - # elif machine.startswith('armv7'): - # api_arch = 'arm' - else: - log.error(f"Unsupported architecture: {machine}") - return None - - return {"os": api_os, "arch": api_arch} - - -# --- Corrected find_java_executable Function --- -async def find_java_executable(extract_dir: pathlib.Path, system: str) -> pathlib.Path | None: - """ - Finds the path to the Java executable within the specified directory. - Checks standard locations based on OS. Corrected scandir usage. - """ - log.info(f"[find_java_executable] Searching in: {extract_dir}") - try: - # Use await for checking the main directory existence (could involve I/O) - if not await aiofiles.os.path.isdir(extract_dir): - log.warning(f"[find_java_executable] Provided path is not a directory: {extract_dir}") - return None - - potential_sub_dir = None - log.debug(f"[find_java_executable] Scanning for subdirectory in {extract_dir}...") - try: - # --- CORRECTED USAGE: Use synchronous os.scandir --- - # No await, no async for. Standard for loop. - for entry in os.scandir(extract_dir): - log.debug(f"[find_java_executable] Found entry: {entry.path} (Name: {entry.name})") - try: - # entry.is_dir() is synchronous - is_dir = entry.is_dir() - log.debug(f"[find_java_executable] Is '{entry.name}' a directory? {is_dir}") - if is_dir: - potential_sub_dir = pathlib.Path(entry.path) - log.info(f"[find_java_executable] Found potential Java subdirectory: {potential_sub_dir}") - break # Assume first directory found is the right one - except OSError as scandir_entry_error: - log.warning(f"[find_java_executable] Could not check directory status for {entry.path}: {scandir_entry_error}") - continue - except OSError as e: - log.warning(f"[find_java_executable] Could not scan directory {extract_dir}: {e}") - # Continue trying base directory paths even if scan fails - - # Determine which directory to check: the found subdir or the base extract dir - base_dir_to_check = potential_sub_dir if potential_sub_dir else extract_dir - log.info(f"[find_java_executable] Selected base directory for final check: {base_dir_to_check}") - - java_executable_path = None - if system == 'Windows': - java_executable_path = base_dir_to_check / 'bin' / 'java.exe' - elif system == 'Darwin': - java_executable_path = base_dir_to_check / 'Contents' / 'Home' / 'bin' / 'java' - else: # Linux - java_executable_path = base_dir_to_check / 'bin' / 'java' - - log.info(f"[find_java_executable] Constructed full path to check: {java_executable_path}") - - # --- Check 1: File Existence (Use await for aiofiles.os.path) --- - try: - is_file = await aiofiles.os.path.isfile(java_executable_path) - log.info(f"[find_java_executable] Check 1: Does path exist as a file? {is_file}") - except Exception as e_isfile: - log.error(f"[find_java_executable] Error checking if path is file: {e_isfile}") - return None - - if is_file: - # --- Check 2: Execute Permission (Use synchronous os.access) --- - try: - is_executable = os.access(java_executable_path, os.X_OK) - log.info(f"[find_java_executable] Check 2: Is file executable (os.X_OK)? {is_executable}") - if is_executable: - log.info(f"[find_java_executable] Success! Found accessible executable: {java_executable_path.resolve()}") - return java_executable_path.resolve() - else: - log.warning(f"[find_java_executable] File found but not executable: {java_executable_path}") - return None - except Exception as e_access: - log.error(f"[find_java_executable] Error checking execute permission with os.access: {e_access}") - return None - else: - log.warning(f"[find_java_executable] Executable path not found or is not a file.") - # Fallback logic (only runs if potential_sub_dir was found but failed the check above) - if potential_sub_dir and base_dir_to_check == potential_sub_dir: - log.info(f"[find_java_executable] Retrying search directly in base directory: {extract_dir}") - fallback_path = None - if system == 'Windows': - fallback_path = extract_dir / 'bin' / 'java.exe' - elif system == 'Darwin': - fallback_path = extract_dir / 'Contents' / 'Home' / 'bin' / 'java' - else: - fallback_path = extract_dir / 'bin' / 'java' - - if fallback_path: - log.info(f"[find_java_executable] Checking fallback path: {fallback_path}") - try: - # Use await for async file check - fb_is_file = await aiofiles.os.path.isfile(fallback_path) - log.info(f"[find_java_executable] Fallback Check 1: Exists as file? {fb_is_file}") - if fb_is_file: - # Use sync permission check - fb_is_executable = os.access(fallback_path, os.X_OK) - log.info(f"[find_java_executable] Fallback Check 2: Is executable? {fb_is_executable}") - if fb_is_executable: - log.info(f"[find_java_executable] Success on fallback! Found: {fallback_path.resolve()}") - return fallback_path.resolve() - else: - log.warning(f"[find_java_executable] Fallback file found but not executable.") - else: - log.warning(f"[find_java_executable] Fallback path not found or not a file.") - except Exception as e_fb: - log.error(f"[find_java_executable] Error during fallback check: {e_fb}") - - log.warning(f"[find_java_executable] Could not find executable via primary or fallback paths.") - return None - - except Exception as e: - log.exception(f"[find_java_executable] Unexpected error searching in {extract_dir}: {e}") # Use log.exception to get traceback - return None - - -# Function to run synchronous extraction in a separate thread -def _extract_zip(zip_data: bytes, dest_path: pathlib.Path): - import io - with io.BytesIO(zip_data) as zip_buffer: - with zipfile.ZipFile(zip_buffer, 'r') as zip_ref: - zip_ref.extractall(dest_path) - -def _extract_tar(tar_path: pathlib.Path, dest_path: pathlib.Path): - # Check if the tar file exists before trying to open it - if not tar_path.is_file(): - log.error(f"Tar file not found for extraction: {tar_path}") - raise FileNotFoundError(f"Tar file not found: {tar_path}") - try: - with tarfile.open(tar_path, "r:gz") as tar_ref: - # tarfile doesn't have a built-in strip_components like the command line - # We need to manually filter members or extract carefully - # For simplicity here, we assume strip=1 behaviour is desired and - # hope the find_java_executable handles the structure. - # A more robust solution would iterate members and adjust paths. - tar_ref.extractall(path=dest_path) # This might create a top-level dir - except tarfile.ReadError as e: - log.error(f"Error reading tar file {tar_path}: {e}") - raise - except Exception as e: - log.error(f"Unexpected error during tar extraction from {tar_path}: {e}") - raise - -# --- Main Exported Function --- -async def download_java( - version: int = DEFAULT_JAVA_VERSION, - destination_dir_str: str | None = None, - image_type: str = DEFAULT_IMAGE_TYPE, - vendor: str = 'eclipse', - jvm_impl: str = 'hotspot', -) -> str | None: - """ - Downloads and extracts a standalone Java runtime/JDK if not already present. - - Args: - version: The major Java version (e.g., 11, 17, 21). - destination_dir_str: Directory for Java. If None, a temporary dir is used. - **Crucially, if this directory already contains a valid executable, download will be skipped.** - image_type: Type of Java package ('jdk' or 'jre'). - vendor: The build vendor (usually 'eclipse' for Temurin). - jvm_impl: The JVM implementation. - - Returns: - The absolute path to the Java executable as a string if successful, otherwise None. - """ - - if destination_dir_str is None: - # Use mkdtemp for a secure temporary directory if none provided - # Note: This temporary directory won't persist across runs. - # A fixed path is usually better for caching. - # Running sync mkdtemp in executor to avoid blocking - loop = asyncio.get_running_loop() - temp_dir_str = await loop.run_in_executor(None, tempfile.mkdtemp, f"downloaded-java-{secrets.token_hex(4)}-") - destination_dir = pathlib.Path(temp_dir_str) - log.info(f"No destination directory provided, using temporary directory: {destination_dir}") - else: - destination_dir = pathlib.Path(destination_dir_str).resolve() - - - platform_info = get_api_os_arch() - if not platform_info: - return None - api_os = platform_info["os"] - api_arch = platform_info["arch"] - current_system = platform.system() - - # --- Check if Java executable already exists --- - log.info(f"Checking for existing Java executable in: {destination_dir}") - try: - existing_java_path = await find_java_executable(destination_dir, current_system) - if existing_java_path: - log.info(f"Valid Java executable already found at: {existing_java_path}. Skipping download.") - return str(existing_java_path) - else: - log.info(f"Existing Java executable not found or installation is incomplete in {destination_dir}.") - except Exception as check_error: - # Log the exception details if the check itself fails - log.exception(f"Error during pre-check for existing Java in {destination_dir}: {check_error}. Assuming download is needed.") - # --- End Check --- - - log.info('Proceeding with Java download and extraction process...') - api_url = f"{ADOPTIUM_API_BASE}/binary/latest/{version}/ga/{api_os}/{api_arch}/{image_type}/{jvm_impl}/normal/{vendor}" - log.info(f"Attempting to download Java {version} ({image_type}) for {api_os}-{api_arch} from Adoptium API.") - - download_url = None - archive_type = None - - async with aiohttp.ClientSession() as session: - try: - log.info(f"Fetching download details (HEAD request) from: {api_url}") - # Use allow_redirects=True and get the final URL - async with session.head(api_url, allow_redirects=True) as head_response: - head_response.raise_for_status() # Raise exception for bad status codes (4xx, 5xx) - download_url = str(head_response.url) # Get the final URL after redirects - - if not download_url: - raise ValueError("Could not resolve download URL after redirects.") - - if download_url.endswith('.zip'): - archive_type = 'zip' - elif download_url.endswith('.tar.gz'): - archive_type = 'tar.gz' - else: - # Guess based on OS if extension is missing (less reliable) - archive_type = 'zip' if api_os == 'windows' else 'tar.gz' - - log.info(f"Resolved download URL: {download_url}") - log.info(f"Detected archive type: {archive_type}") - - # Ensure destination directory exists - # Use await aiofiles.os.makedirs - await aiofiles.os.makedirs(destination_dir, exist_ok=True) - log.info(f"Ensured destination directory exists: {destination_dir}") - - log.info('Starting download...') - async with session.get(download_url) as response: - response.raise_for_status() - file_data = await response.read() - log.info('Download complete.') - - log.info(f"Extracting {archive_type} archive to {destination_dir}...") - loop = asyncio.get_running_loop() - - if archive_type == 'zip': - # Run synchronous zip extraction in a thread - await loop.run_in_executor(None, _extract_zip, file_data, destination_dir) - else: # tar.gz - # Write to a temporary file first for tarfile - temp_tar_path = None - # Use a context manager for the temporary file - # Running sync tempfile operations in executor - fd, temp_tar_path_str = await loop.run_in_executor(None, tempfile.mkstemp, ".tar.gz", "java-dl-") - os.close(fd) # Close descriptor from mkstemp - temp_tar_path = pathlib.Path(temp_tar_path_str) - - try: - log.debug(f"Saving temporary tar archive to: {temp_tar_path}") - async with aiofiles.open(temp_tar_path, 'wb') as f: - await f.write(file_data) - log.debug(f"Temporary archive saved successfully.") - - # Run synchronous tar extraction in a thread - log.debug(f"Starting tar extraction from {temp_tar_path}...") - await loop.run_in_executor(None, _extract_tar, temp_tar_path, destination_dir) - log.debug('Extraction using tar complete.') - - finally: - # Clean up temporary tar file using await aiofiles.os.remove - if temp_tar_path and await aiofiles.os.path.exists(temp_tar_path): - try: - await aiofiles.os.remove(temp_tar_path) - log.debug(f"Temporary file {temp_tar_path} deleted.") - except OSError as e: - log.warning(f"Could not delete temporary tar file {temp_tar_path}: {e}") - elif temp_tar_path: - log.debug(f"Temporary file {temp_tar_path} did not exist for deletion.") - - log.info('Extraction complete.') - - # --- Find executable AFTER extraction --- - # Add a small delay, just in case of filesystem flush issues (optional) - # await asyncio.sleep(0.5) - log.info("Re-checking for Java executable after extraction...") - java_path = await find_java_executable(destination_dir, current_system) - - if java_path: - log.info(f"Java executable successfully found after extraction at: {java_path}") - return str(java_path) - else: - log.error('Extraction seemed successful, but failed to find Java executable at the expected location afterwards.') - log.error(f"Please double-check the contents of {destination_dir} and the logic in find_java_executable for platform {current_system}.") - # Log directory contents for debugging - try: - log.error(f"Contents of {destination_dir}: {os.listdir(destination_dir)}") - # Check potential subdir contents too - for item in os.listdir(destination_dir): - item_path = destination_dir / item - if item_path.is_dir(): - log.error(f"Contents of {item_path}: {os.listdir(item_path)}") - bin_path = item_path / 'bin' - if bin_path.is_dir(): - log.error(f"Contents of {bin_path}: {os.listdir(bin_path)}") - break # Show first subdir found - except Exception as list_err: - log.error(f"Could not list directory contents for debugging: {list_err}") - return None - - except aiohttp.ClientResponseError as e: - log.error(f"HTTP Error downloading Java: {e.status} {e.message}") - if e.status == 404: - log.error(f"Could not find a build for Java {version} ({image_type}) for {api_os}-{api_arch}. Check Adoptium website for availability.") - else: - log.error(f"Response Headers: {e.headers}") - # Try reading response body if available (might be large) - try: - error_body = await e.response.text() - log.error(f"Response Body (partial): {error_body[:500]}") - except Exception: - pass # Ignore if body can't be read - - log.error(f"Java download/extraction failed. Directory {destination_dir} may be incomplete.") - return None - except Exception as error: - log.exception(f"An unexpected error occurred during download/extraction: {error}") - log.error(f"Java download/extraction failed. Directory {destination_dir} may be incomplete.") - return None - -# Example usage (optional, can be run with `python java.py`) -if __name__ == "__main__": - async def run_test(): - print("Testing Java Downloader...") - # Define a test destination directory - test_dest = pathlib.Path("./test-java-runtime").resolve() - print(f"Will attempt to download Java to: {test_dest}") - - # Clean up previous test run if exists - if test_dest.exists(): - import shutil - print("Removing previous test directory...") - shutil.rmtree(test_dest) - - java_exe_path = await download_java( - version=21, # Specify version - destination_dir_str=str(test_dest), - image_type='jdk' - ) - - if java_exe_path: - print(f"\nSuccess! Java executable path: {java_exe_path}") - # Try running java -version - try: - print("\nRunning java -version:") - proc = await asyncio.create_subprocess_exec( - java_exe_path, - "-version", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, stderr = await proc.communicate() - # java -version often prints to stderr - print("Exit Code:", proc.returncode) - if stderr: - print("Output (stderr):\n", stderr.decode()) - if stdout: # Just in case it prints to stdout - print("Output (stdout):\n", stdout.decode()) - - except Exception as e: - print(f"Error running java -version: {e}") - else: - print("\nFailed to download or find Java executable.") - - asyncio.run(run_test()) \ No newline at end of file diff --git a/replacer.py b/replacer.py deleted file mode 100644 index 16a53f0..0000000 --- a/replacer.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging - -# Configure logging (optional, but good practice) -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -log = logging.getLogger(__name__) - -def replace_text(value: str, replacements: dict) -> str: - """ - Replaces all occurrences of specified substrings within a string. - Does not use regular expressions. - - Args: - value: The original string to perform replacements on. - replacements: A dictionary where keys are the substrings - to find and values are the strings to - replace them with. - - Returns: - The string with all specified replacements made. - Returns the original value if it's not a string - or if replacements is not a valid dictionary. - """ - if not isinstance(value, str): - log.warning("replace_text: Input 'value' is not a string. Returning original value.") - return value - - if not isinstance(replacements, dict): - log.warning("replace_text: Input 'replacements' is not a valid dictionary. Returning original value.") - return value - - modified_value = value - - # Iterate through each key-value pair in the replacements dictionary - for search_string, replace_string in replacements.items(): - # Ensure both search and replace values are strings for safety - if isinstance(search_string, str) and isinstance(replace_string, str): - # Use str.replace() to replace all occurrences - modified_value = modified_value.replace(search_string, replace_string) - else: - log.warning(f"replace_text: Skipping replacement for key '{search_string}' as either key or value is not a string.") - - return modified_value - -# Example Usage (matches the JS comment example) -# if __name__ == "__main__": -# import os -# __dirname = os.path.abspath('.') # Simulate __dirname for example - -# original_config = { -# "executable": ":thisdir:/bin/launcher", -# "configFile": "/etc/config.conf", -# "logPath": ":thisdir:/logs/app.log", -# "tempDir": "/tmp", -# "description": "Uses :thisdir: multiple :thisdir: times." -# } - -# replacements = {":thisdir:": __dirname} - -# patched_config = {} -# for key, value in original_config.items(): -# patched_config[key] = replace_text(value, replacements) # Note: Not async in Python - -# print("Original Config:", original_config) -# print("Patched Config:", patched_config) - -# # Expected Output (assuming __dirname = '/path/to/current/directory'): -# # Original Config: {'executable': ':thisdir:/bin/launcher', 'configFile': '/etc/config.conf', 'logPath': ':thisdir:/logs/app.log', 'tempDir': '/tmp', 'description': 'Uses :thisdir: multiple :thisdir: times.'} -# # Patched Config: {'executable': '/path/to/current/directory/bin/launcher', 'configFile': '/etc/config.conf', 'logPath': '/path/to/current/directory/logs/app.log', 'tempDir': '/tmp', 'description': 'Uses /path/to/current/directory multiple /path/to/current/directory times.'} \ No newline at end of file From feaa4c2e558965e200646db6e8dfea170212c09b Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Tue, 6 May 2025 12:10:20 +0200 Subject: [PATCH 04/19] Add package --- package-lock.json | 3 +++ package.json | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 5706696..6f45c91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,9 @@ "uuid": "^11.1.0", "windows-shortcuts": "^0.1.6" }, + "bin": { + "minecraft": "npm run start" + }, "devDependencies": { "electron": "^36.1.0", "electron-builder": "^26.0.12" diff --git a/package.json b/package.json index e1dff39..36566c0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "type": "module", "main": "index.js", "scripts": { - "start": "node .", + "start": "electron ui_index.cjs", "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { @@ -23,5 +23,8 @@ "devDependencies": { "electron": "^36.1.0", "electron-builder": "^26.0.12" + }, + "bin": { + "minecraft": "electron ui_index.cjs" } } From 26be77c90794e0f546ef415c1eb411e60c478abb Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Tue, 6 May 2025 12:17:31 +0200 Subject: [PATCH 05/19] Add test --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 36566c0..9f65368 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "main": "index.js", "scripts": { "start": "electron ui_index.cjs", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node ." }, "dependencies": { "@xterm/xterm": "^5.5.0", From dce9a3e1348de37442fe193174268222cea24ca3 Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Wed, 7 May 2025 12:26:24 +0200 Subject: [PATCH 06/19] Add Vitest for testing and implement initial API tests - Updated package.json to include Vitest as a dev dependency. - Created api.test.js to add tests for the API, including checks for OS name, rule validation, Java API, manifest handling, and miscellaneous functions. - Ensured tests cover both defined methods and expected behaviors. --- .gitignore | 3 +- api.test.js | 66 +++ index.js | 26 +- package-lock.json | 1414 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 3 +- 5 files changed, 1502 insertions(+), 10 deletions(-) create mode 100644 api.test.js diff --git a/.gitignore b/.gitignore index 874c3ea..08be659 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ java-runtime .DS_Store dist build -__pycache__ \ No newline at end of file +__pycache__ +test.txt \ No newline at end of file diff --git a/api.test.js b/api.test.js new file mode 100644 index 0000000..82ad637 --- /dev/null +++ b/api.test.js @@ -0,0 +1,66 @@ +// windows only test +import index from "./index.js" +import * as java from "./java.js" +import { describe, it, expect } from "vitest" +import fs from "fs/promises" + +describe("API exists", () => { + it("Should be defined", () => { + expect(index).toBeDefined() + }) +}) + +describe("API methods", () => { + it("Can get OS name", () => { + expect(index.getOSName()).toBeDefined() + expect(index.getOSName()).eq("windows") + }) + it("Can check rules", () => { + expect(index.checkRule({ + "action": "allow", + "os": { + "name": "osx" + } + })).eq(false) + expect(index.checkRule({ + "action": "allow", + "os": { + "name": "linux" + } + })).eq(false) + expect(index.checkRule({ + "action": "allow", + "os": { + "name": "windows" + } + })).eq(true) + }) +}) + +describe("Java API", () => { + it("Should be defined", () => { + expect(java.downloadJava).toBeDefined() + expect(java.downloadJava).toBeTypeOf("function") + }) +}) + +describe("Can handle manifest", () => { + it("Should be defined", async () => { + expect(await index.loadManifest("neoforge-21.1.162.json")).toBeDefined() + }) + it("Should merge Manifests", async () => { + expect(await index.mergeManifests(await index.loadManifest("neoforge-21.1.162.json"), await index.loadManifest("1.21.1.json"))).toBeDefined() + expect(await index.mergeManifests(await index.loadManifest("neoforge-21.1.162.json"), await index.loadManifest("1.21.1.json"))).toBeTypeOf("object") + }) +}) + +describe("Misc functions", () => { + it("Should be defined", async () => { + expect(index.downloadFile).toBeDefined() + await fs.rm("test.txt", { force: true }) + const download = await index.downloadFile("https://example.com", "test.txt") + expect(download).toBeDefined() + expect(download).toBeTypeOf("boolean") + expect(download).eq(true) + }) +}) \ No newline at end of file diff --git a/index.js b/index.js index 635c1ec..9beab18 100644 --- a/index.js +++ b/index.js @@ -823,8 +823,24 @@ async function main() { } // End of main function -main().catch(error => { - console.error("\n--- An error occurred during setup or launch ---"); - console.error(error); - process.exit(1); -}); \ No newline at end of file +const entryPointScript = process.argv[1].split(path.sep).pop(); +if (entryPointScript === __filename || entryPointScript === __dirname.split(path.sep).pop()) { + main().catch(error => { + console.error("\n--- An error occurred during setup or launch ---"); + console.error(error); + process.exit(1); + }); +} + +export default { + downloadFile, + extractNatives, + getOSName, + getArchName, + checkRule, + checkItemRules, + ensureLauncherProfiles, + loadManifest, + mergeManifests, + main +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6f45c91..ee4693d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,11 +19,12 @@ "windows-shortcuts": "^0.1.6" }, "bin": { - "minecraft": "npm run start" + "minecraft": "electron ui_index.cjs" }, "devDependencies": { "electron": "^36.1.0", - "electron-builder": "^26.0.12" + "electron-builder": "^26.0.12", + "vitest": "^3.1.3" } }, "node_modules/@develar/schema-utils": { @@ -750,6 +751,431 @@ "node": ">= 10.0.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -872,6 +1298,13 @@ "node": ">=18.0.0" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -1016,6 +1449,286 @@ "node": ">=14" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", + "integrity": "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz", + "integrity": "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz", + "integrity": "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz", + "integrity": "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz", + "integrity": "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz", + "integrity": "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz", + "integrity": "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz", + "integrity": "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz", + "integrity": "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz", + "integrity": "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz", + "integrity": "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz", + "integrity": "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz", + "integrity": "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz", + "integrity": "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz", + "integrity": "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz", + "integrity": "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz", + "integrity": "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz", + "integrity": "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz", + "integrity": "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz", + "integrity": "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -1075,6 +1788,13 @@ "@types/ms": "*" } }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -1160,6 +1880,119 @@ "@types/node": "*" } }, + "node_modules/@vitest/expect": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.3.tgz", + "integrity": "sha512-7FTQQuuLKmN1Ig/h+h/GO+44Q1IlglPlR2es4ab7Yvfx+Uk5xsv+Ykk+MEt/M2Yn/xGmzaLKxGw2lgy2bwuYqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.1.3", + "@vitest/utils": "3.1.3", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.3.tgz", + "integrity": "sha512-PJbLjonJK82uCWHjzgBJZuR7zmAOrSvKk1QBxrennDIgtH4uK0TB1PvYmc0XBCigxxtiAVPfWtAdy4lpz8SQGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.3.tgz", + "integrity": "sha512-i6FDiBeJUGLDKADw2Gb01UtUNb12yyXAqC/mmRWuYl+m/U9GS7s8us5ONmGkGpUUo7/iAYzI2ePVfOZTYvUifA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.3.tgz", + "integrity": "sha512-Tae+ogtlNfFei5DggOsSUvkIaSuVywujMj6HzR97AHK6XK8i3BuVyIifWAm/sE3a15lF5RH9yQIrbXYuo0IFyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.1.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.3.tgz", + "integrity": "sha512-XVa5OPNTYUsyqG9skuUkFzAeFnEzDp8hQu7kZ0N25B1+6KjGm4hWLtURyBbsIAOekfWQ7Wuz/N/XXzgYO3deWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.1.3", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.3.tgz", + "integrity": "sha512-x6w+ctOEmEXdWaa6TO4ilb7l9DxPR5bwEb6hILKuxfU1NqWT2mpJD9NJN7t3OTfxmVlOMrvtoFJGdgyzZ605lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-2Ltrpht4OmHO9+c/nmHtF09HWiyWdworqnHIwjfvDyWjuwKbdkcS9AnhsDn+8E2RM4x++foD1/tNuLPVvWG1Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.1.3", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -1498,6 +2331,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -1732,6 +2575,16 @@ "node": ">= 10.0.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cacache": { "version": "16.1.3", "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", @@ -1933,6 +2786,23 @@ "node": ">= 0.4" } }, + "node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1950,6 +2820,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2292,6 +3172,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -2815,6 +3705,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2848,7 +3745,48 @@ "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", "dev": true, "license": "MIT", - "optional": true + "optional": true + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } }, "node_modules/escalade": { "version": "3.2.0", @@ -2874,6 +3812,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", @@ -2937,6 +3895,21 @@ "pend": "~1.2.0" } }, + "node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -3115,6 +4088,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3813,6 +4801,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loupe": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "dev": true, + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -3843,6 +4838,16 @@ "dev": true, "license": "ISC" }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, "node_modules/make-fetch-happen": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", @@ -4273,6 +5278,25 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -4561,6 +5585,23 @@ "dev": true, "license": "ISC" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -4583,6 +5624,26 @@ "dev": true, "license": "MIT" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -4598,6 +5659,35 @@ "node": ">=10.4.0" } }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postject": { "version": "1.0.0-alpha.6", "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", @@ -4845,6 +5935,46 @@ "node": ">=8.0" } }, + "node_modules/rollup": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", + "integrity": "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.40.2", + "@rollup/rollup-android-arm64": "4.40.2", + "@rollup/rollup-darwin-arm64": "4.40.2", + "@rollup/rollup-darwin-x64": "4.40.2", + "@rollup/rollup-freebsd-arm64": "4.40.2", + "@rollup/rollup-freebsd-x64": "4.40.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.2", + "@rollup/rollup-linux-arm-musleabihf": "4.40.2", + "@rollup/rollup-linux-arm64-gnu": "4.40.2", + "@rollup/rollup-linux-arm64-musl": "4.40.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-musl": "4.40.2", + "@rollup/rollup-linux-s390x-gnu": "4.40.2", + "@rollup/rollup-linux-x64-gnu": "4.40.2", + "@rollup/rollup-linux-x64-musl": "4.40.2", + "@rollup/rollup-win32-arm64-msvc": "4.40.2", + "@rollup/rollup-win32-ia32-msvc": "4.40.2", + "@rollup/rollup-win32-x64-msvc": "4.40.2", + "fsevents": "~2.3.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4948,6 +6078,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -5061,6 +6198,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -5112,6 +6259,13 @@ "dev": true, "license": "ISC" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -5122,6 +6276,13 @@ "node": ">= 6" } }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -5344,6 +6505,67 @@ "semver": "bin/semver" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -5498,6 +6720,175 @@ "node": ">=0.6.0" } }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.3.tgz", + "integrity": "sha512-uHV4plJ2IxCl4u1up1FQRrqclylKAogbtBfOTwcuJ28xFi+89PZ57BRh+naIRvH70HPwxy5QHYzg1OrEaC7AbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.3.tgz", + "integrity": "sha512-188iM4hAHQ0km23TN/adso1q5hhwKqUpv+Sd6p5sOuh6FhQnRNW3IsiIpvxqahtBabsJ2SLZgmGSpcYK4wQYJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "3.1.3", + "@vitest/mocker": "3.1.3", + "@vitest/pretty-format": "^3.1.3", + "@vitest/runner": "3.1.3", + "@vitest/snapshot": "3.1.3", + "@vitest/spy": "3.1.3", + "@vitest/utils": "3.1.3", + "chai": "^5.2.0", + "debug": "^4.4.0", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.13", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.1.3", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.1.3", + "@vitest/ui": "3.1.3", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -5533,6 +6924,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/windows-shortcuts": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/windows-shortcuts/-/windows-shortcuts-0.1.6.tgz", diff --git a/package.json b/package.json index 9f65368..44c6457 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ }, "devDependencies": { "electron": "^36.1.0", - "electron-builder": "^26.0.12" + "electron-builder": "^26.0.12", + "vitest": "^3.1.3" }, "bin": { "minecraft": "electron ui_index.cjs" From 208aebbc0af68aa50f81043b12bad14162f6f463 Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Wed, 7 May 2025 12:32:10 +0200 Subject: [PATCH 07/19] Update downloadFile test to use a valid URL --- api.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api.test.js b/api.test.js index 82ad637..4f7ae9a 100644 --- a/api.test.js +++ b/api.test.js @@ -57,8 +57,10 @@ describe("Can handle manifest", () => { describe("Misc functions", () => { it("Should be defined", async () => { expect(index.downloadFile).toBeDefined() + }) + it("Should download file", async () => { await fs.rm("test.txt", { force: true }) - const download = await index.downloadFile("https://example.com", "test.txt") + const download = await index.downloadFile("https://sample-files.com/downloads/documents/txt/simple.txt", "test.txt") expect(download).toBeDefined() expect(download).toBeTypeOf("boolean") expect(download).eq(true) From e1d4d8097280fb08cafb8c9ae71c9eb051a798fc Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Wed, 7 May 2025 12:37:45 +0200 Subject: [PATCH 08/19] Remove unused dependencies from package.json and package-lock.json --- package-lock.json | 16 +--------------- package.json | 4 +--- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index ee4693d..4d63a31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,12 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@xterm/xterm": "^5.5.0", "adm-zip": "^0.5.16", "axios": "^1.9.0", "cli-progress": "^3.12.0", "node-fetch": "^3.3.2", "tar": "^7.4.3", - "uuid": "^11.1.0", - "windows-shortcuts": "^0.1.6" + "uuid": "^11.1.0" }, "bin": { "minecraft": "electron ui_index.cjs" @@ -2003,12 +2001,6 @@ "node": ">=10.0.0" } }, - "node_modules/@xterm/xterm": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", - "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" - }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -6941,12 +6933,6 @@ "node": ">=8" } }, - "node_modules/windows-shortcuts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/windows-shortcuts/-/windows-shortcuts-0.1.6.tgz", - "integrity": "sha512-kjkb3Hmmmg7jwnOb+29AOmoEEA1L/JeLsMOYovpLxYpuc+fN0R+pr8sMwep3JFhUZloxyw1XTzq8n3HugXkqBA==", - "license": "MIT" - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 44c6457..f33c048 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,12 @@ "test": "node ." }, "dependencies": { - "@xterm/xterm": "^5.5.0", "adm-zip": "^0.5.16", "axios": "^1.9.0", "cli-progress": "^3.12.0", "node-fetch": "^3.3.2", "tar": "^7.4.3", - "uuid": "^11.1.0", - "windows-shortcuts": "^0.1.6" + "uuid": "^11.1.0" }, "devDependencies": { "electron": "^36.1.0", From fdb8a05086f68ccf485b38eea9a89aedf3d79686 Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Wed, 7 May 2025 12:39:24 +0200 Subject: [PATCH 09/19] Add GitHub Actions workflow for ViTest testing --- .github/workflows/test.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..53a97f2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Test with ViTest +on: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "16" + cache: "npm" + - run: npm ci + - run: npx vitest --run --coverage + env: + CI: true \ No newline at end of file From 6843adaa342c9f15bf7faaf2455cecb676d6d35a Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Wed, 7 May 2025 12:42:45 +0200 Subject: [PATCH 10/19] Update GitHub Actions workflow to target gui-preview branch and upgrade Node.js version to 22 --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 53a97f2..a287751 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,9 +1,9 @@ name: Test with ViTest on: push: - branches: [main] + branches: [gui-preview] pull_request: - branches: [main] + branches: [gui-preview] jobs: test: runs-on: windows-latest @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "16" + node-version: "22" cache: "npm" - run: npm ci - run: npx vitest --run --coverage From 43c55cd057d25837d5d70cf17ac550f3741b2664 Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Wed, 7 May 2025 12:44:25 +0200 Subject: [PATCH 11/19] Add Vitest coverage dependency to package.json and package-lock.json --- package-lock.json | 318 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 319 insertions(+) diff --git a/package-lock.json b/package-lock.json index 4d63a31..06962d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,11 +20,86 @@ "minecraft": "electron ui_index.cjs" }, "devDependencies": { + "@vitest/coverage-v8": "^3.1.3", "electron": "^36.1.0", "electron-builder": "^26.0.12", "vitest": "^3.1.3" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", + "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -1296,6 +1371,51 @@ "node": ">=18.0.0" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -1303,6 +1423,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -1878,6 +2009,39 @@ "@types/node": "*" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.3.tgz", + "integrity": "sha512-cj76U5gXCl3g88KSnf80kof6+6w+K4BjOflCl7t6yRJPDuCrHtVu0SgNYOUARJOL5TI8RScDbm5x4s1/P9bvpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "debug": "^4.4.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.1.3", + "vitest": "3.1.3" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.3.tgz", @@ -4385,6 +4549,13 @@ "node": ">=10" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -4628,6 +4799,60 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -4840,6 +5065,47 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-fetch-happen": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", @@ -6477,6 +6743,58 @@ "rimraf": "bin.js" } }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", diff --git a/package.json b/package.json index f33c048..0f7d538 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "uuid": "^11.1.0" }, "devDependencies": { + "@vitest/coverage-v8": "^3.1.3", "electron": "^36.1.0", "electron-builder": "^26.0.12", "vitest": "^3.1.3" From 041871b94a12636004bb4652c95bf6dfd30a95e7 Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Wed, 7 May 2025 12:55:08 +0200 Subject: [PATCH 12/19] Update .gitignore to include coverage directory and add @vitest/ui dependency in package.json and package-lock.json --- .gitignore | 3 +- package-lock.json | 79 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 08be659..9511d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ java-runtime dist build __pycache__ -test.txt \ No newline at end of file +test.txt +coverage \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 06962d6..6202fdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@vitest/coverage-v8": "^3.1.3", + "@vitest/ui": "^3.1.3", "electron": "^36.1.0", "electron-builder": "^26.0.12", "vitest": "^3.1.3" @@ -1578,6 +1579,13 @@ "node": ">=14" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.40.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", @@ -2140,6 +2148,28 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/ui": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.1.3.tgz", + "integrity": "sha512-IipSzX+8DptUdXN/GWq3hq5z18MwnpphYdOMm0WndkRGYELzfq7NDP8dMpZT7JGW1uXFrIGxOW2D0Xi++ulByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.1.3", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.13", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.1.3" + } + }, "node_modules/@vitest/utils": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.3.tgz", @@ -4089,6 +4119,13 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -4112,6 +4149,13 @@ "node": ">=10" } }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -5529,6 +5573,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6376,6 +6430,21 @@ "node": ">=10" } }, + "node_modules/sirv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", + "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/slice-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", @@ -6896,6 +6965,16 @@ "tmp": "^0.2.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", diff --git a/package.json b/package.json index 0f7d538..d0be05b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@vitest/coverage-v8": "^3.1.3", + "@vitest/ui": "^3.1.3", "electron": "^36.1.0", "electron-builder": "^26.0.12", "vitest": "^3.1.3" From fa816b8c0a4bc55aa2308fe5ddfa9e24335032b3 Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Fri, 9 May 2025 08:10:18 +0200 Subject: [PATCH 13/19] Add support for UI launch in the launcher and entry point script --- index.js | 4 ++-- ui_index.cjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index 9beab18..c0f749b 100644 --- a/index.js +++ b/index.js @@ -822,9 +822,9 @@ async function main() { } // End of main function - +// --ui adds fix to the launcher (when the ui launches, it returns ui_index.cjs as the entry point script, which will trigger module logic, which is now fixed) const entryPointScript = process.argv[1].split(path.sep).pop(); -if (entryPointScript === __filename || entryPointScript === __dirname.split(path.sep).pop()) { +if (entryPointScript === __filename || entryPointScript === __dirname.split(path.sep).pop() || (process.argv.length == 3 && process.argv[2] == "--ui")) { main().catch(error => { console.error("\n--- An error occurred during setup or launch ---"); console.error(error); diff --git a/ui_index.cjs b/ui_index.cjs index 96c8006..d648f71 100644 --- a/ui_index.cjs +++ b/ui_index.cjs @@ -32,7 +32,7 @@ const createConsole = () => { app.whenReady().then(() => { ipcMain.on('launch', (event, arg) => { let consoleWin = createConsole(); - const process = spawn('node', [mainIndex]); + const process = spawn('node', [mainIndex, "--ui"]); process.stdout.on('data', (data) => { consoleWin.webContents.send('msg', String(data)); // Send to renderer From 033eb43beaa1fe47deca06186ca8d137e9c5689e Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Fri, 9 May 2025 09:16:59 +0200 Subject: [PATCH 14/19] Implement search functionality in mod dashboard and update script type to module --- ui/chrome/js/moddashboard.js | 23 +++++++++++++++++++++++ ui/chrome/moddashboard/index.html | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ui/chrome/js/moddashboard.js b/ui/chrome/js/moddashboard.js index e69de29..851b4d5 100644 --- a/ui/chrome/js/moddashboard.js +++ b/ui/chrome/js/moddashboard.js @@ -0,0 +1,23 @@ +document.getElementById("search").addEventListener("click", async function() { + let search = document.getElementById("searchText").value; + let searchUrl = "https://mc-backend-six.vercel.app/api/search?q=" + encodeURIComponent(search) + const searchResult = await fetch(searchUrl) + const data = await searchResult.json() + let result = data.hits + let resultDiv = document.getElementById("result") + resultDiv.innerHTML = "" + for (var i = 0; i < result.length; i++) { + let project = result[i] + let projectDiv = document.createElement("div") + projectDiv.className = "project" + projectDiv.innerHTML = ` + ${project.title} +

${project.title}

+

${project.description}

+

Downloads: ${project.downloads}

+

Follows: ${project.follows}

+ View on Modrinth + ` + resultDiv.appendChild(projectDiv) + } +}); \ No newline at end of file diff --git a/ui/chrome/moddashboard/index.html b/ui/chrome/moddashboard/index.html index 3aa0c47..f70eeb9 100644 --- a/ui/chrome/moddashboard/index.html +++ b/ui/chrome/moddashboard/index.html @@ -24,5 +24,5 @@

Mod Details

- + \ No newline at end of file From 68ddc1003c7b52d4d3e19a0db315443c34efdc9c Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Fri, 9 May 2025 09:19:30 +0200 Subject: [PATCH 15/19] Refactor mod dashboard layout by removing mod list and details sections, and enhancing search functionality. --- ui/chrome/moddashboard/index.html | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/ui/chrome/moddashboard/index.html b/ui/chrome/moddashboard/index.html index f70eeb9..c7aa512 100644 --- a/ui/chrome/moddashboard/index.html +++ b/ui/chrome/moddashboard/index.html @@ -11,18 +11,13 @@

Mod Dashboard

-
-

Installed Mods

-
    - -
-
-
-

Mod Details

-
- -
-
+
+ + +
+
+ +
\ No newline at end of file From 9d0881ebdea437a482f5ccfb75e62cc48755d9f2 Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Fri, 9 May 2025 10:35:11 +0200 Subject: [PATCH 16/19] Add install button and functionality to mod dashboard for easier mod installation --- ui/chrome/css/main.css | 18 ++++++++++++++++++ ui/chrome/js/moddashboard.js | 1 + ui/chrome/moddashboard/index.html | 16 +++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/ui/chrome/css/main.css b/ui/chrome/css/main.css index 6b0d48d..fdfa898 100644 --- a/ui/chrome/css/main.css +++ b/ui/chrome/css/main.css @@ -24,4 +24,22 @@ body { #terminal pre .error { color: red; +} + +.project { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 20px; + /** Add an outer border to the project section */ + border: 1px solid #ccc; +} + +.project h3 { + margin-bottom: 10px; +} + +.project p { + margin: 0; + font-size: 14px; } \ No newline at end of file diff --git a/ui/chrome/js/moddashboard.js b/ui/chrome/js/moddashboard.js index 851b4d5..ef326f6 100644 --- a/ui/chrome/js/moddashboard.js +++ b/ui/chrome/js/moddashboard.js @@ -17,6 +17,7 @@ document.getElementById("search").addEventListener("click", async function() {

Downloads: ${project.downloads}

Follows: ${project.follows}

View on Modrinth + ` resultDiv.appendChild(projectDiv) } diff --git a/ui/chrome/moddashboard/index.html b/ui/chrome/moddashboard/index.html index c7aa512..ffb29db 100644 --- a/ui/chrome/moddashboard/index.html +++ b/ui/chrome/moddashboard/index.html @@ -1,11 +1,24 @@ + Mod Dashboard + +

Mod Dashboard

@@ -16,8 +29,9 @@

Mod Dashboard

- +
+ \ No newline at end of file From 66010044687f9644a89fe316b2cf8b242ca25b01 Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Fri, 9 May 2025 11:01:47 +0200 Subject: [PATCH 17/19] Implement mod downloading functionality and enhance dependency handling in mod dashboard --- ui/chrome/moddashboard/index.html | 34 ++++++++++++++++++++++++------ ui/externalScripts/main_preload.js | 3 +++ ui_index.cjs | 28 +++++++++++++++++++----- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/ui/chrome/moddashboard/index.html b/ui/chrome/moddashboard/index.html index ffb29db..c354274 100644 --- a/ui/chrome/moddashboard/index.html +++ b/ui/chrome/moddashboard/index.html @@ -8,19 +8,39 @@
+ Back

Mod Dashboard

diff --git a/ui/externalScripts/main_preload.js b/ui/externalScripts/main_preload.js index 74c0498..6d2c0e3 100644 --- a/ui/externalScripts/main_preload.js +++ b/ui/externalScripts/main_preload.js @@ -3,5 +3,8 @@ const {ipcRenderer,contextBridge} = require('electron'); contextBridge.exposeInMainWorld('mcAPI', { launch: () => { ipcRenderer.send('launch'); + }, + downloadToModsFolder: (url) => { + ipcRenderer.send('downloadToModsFolder', url); } }) \ No newline at end of file diff --git a/ui_index.cjs b/ui_index.cjs index d648f71..ee515b8 100644 --- a/ui_index.cjs +++ b/ui_index.cjs @@ -33,25 +33,25 @@ app.whenReady().then(() => { ipcMain.on('launch', (event, arg) => { let consoleWin = createConsole(); const process = spawn('node', [mainIndex, "--ui"]); - + process.stdout.on('data', (data) => { consoleWin.webContents.send('msg', String(data)); // Send to renderer console.log(`stdout: ${data}`); }); - + process.stderr.on('data', (data) => { consoleWin.webContents.send('error', String(data)); // Send to renderer console.error(`stderr: ${data}`); }); - + process.on('close', (code) => { console.log(`child process exited with code ${code}`); }); - + process.on('error', (error) => { console.error(`Error: ${error}`); }); - + process.on('exit', (code) => { console.log(`Process exited with code: ${code}`); event.reply('process-exit', code); @@ -59,5 +59,23 @@ app.whenReady().then(() => { consoleWin = null; }); }); + ipcMain.on("downloadToModsFolder", (event, url) => { + fetch(url) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.arrayBuffer(); + }) + .then(buffer => { + const modsFolder = path.join(__dirname, ".minecraft", 'mods'); + if (!fs.existsSync(modsFolder)) { + fs.mkdirSync(modsFolder); + } + const filePath = path.join(modsFolder, decodeURIComponent(path.basename(url))); + fs.writeFileSync(filePath, Buffer.from(buffer)); + event.reply('download-complete', filePath); + }) + }); createWindow(); }); \ No newline at end of file From 43da4a45b5a6bb93fda0b72f910886a94278dbeb Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Fri, 9 May 2025 11:02:24 +0200 Subject: [PATCH 18/19] Fix body margin and padding for consistent layout in main.css --- ui/chrome/css/main.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/chrome/css/main.css b/ui/chrome/css/main.css index fdfa898..70d9893 100644 --- a/ui/chrome/css/main.css +++ b/ui/chrome/css/main.css @@ -2,6 +2,8 @@ body { background-color: #ffffff; font-family: Arial, sans-serif; color: #333; + margin: 0; + padding: 20px; } #terminal { From 6494a36451d59eee69934fc584bb23abd433b10c Mon Sep 17 00:00:00 2001 From: StoppedwummSites <150438484+StoppedwummSites@users.noreply.github.com> Date: Fri, 9 May 2025 11:04:22 +0200 Subject: [PATCH 19/19] Replace deprecated fs.rmdir with fs.rm for removing old backup in main function --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index c0f749b..afccbbf 100644 --- a/index.js +++ b/index.js @@ -812,7 +812,7 @@ async function main() { }); }) // removing old backup - await fs.rmdir(BACKUP_PATH, { recursive: true, force: true }); + await fs.rm(BACKUP_PATH, { recursive: true, force: true }); } catch (error) { console.error(`Failed to create backup: ${error}`);