diff --git a/.gitignore b/.gitignore index 9ffdb54..a8e4741 100644 --- a/.gitignore +++ b/.gitignore @@ -20,10 +20,12 @@ core/modules/__pycache__/ # Jackify engine temp and output files jackify-install-engine/temp/ -jackify/engine/temp/ jackify-install-engine/*.log jackify-install-engine/*.dds +# jackify-engine is now distributed via GitHub releases, not bundled in the repo +jackify/engine/ + # Editor/OS temp files *.tmp *.bak @@ -43,6 +45,3 @@ requirements-packaging.txt # Development logs logs/ -# NOTE: .dll, .json, .deps.json, .runtimeconfig.json in jackify-install-engine/ -# are currently tracked because it's unclear if they are required for runtime/distribution -# or are build artifacts. Do not ignore until this is confirmed. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fac7f18..412f505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Jackify Changelog +## v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support +**Release Date:** TBD + +### Engine Choice and Tools Hub +- jackify-engine is no longer stored in the Jackify repository. It is fetched from GitHub during the AppImage build and can be updated independently via the Jackify Tools Hub without requiring a new Jackify release. +- EXPERIMENTAL: Choose between jackify-engine (hash-matched with Wabbajack, proven) and CLF3 (faster install, not hash-matched with Wabbajack). Use the **Use Experimental Engine** checkbox on the Install Modlist screen, or switch permanently via the Tools Hub. +- A new Tools Hub available from Additional Tasks: download, update, downgrade a growing list of additional tools such as TTW Linux Installer and Radium Textures, as well as install engines. + +### NXM Link Handling +- Jackify can become the registered nxm:// handler for your system. +- Clicking a Nexus "Mod Manager Download" or "Vortex" button link while MO2 is open routes the download directly to MO2 in your active modlist prefix. +- If MO2 is not running, the link opens Jackify to a standalone dialog to select which modlist to send the download to. + +### .NET Script Framework (NSF) / Custom Skills Framework (CSF) Support +- Skyrim 1.5.97 Modlists using NetScriptFramework and Custom Skills Framework are now detected automatically. dotnet48 is installed via the bundled winetricks verb after Wine Mono is removed, then the prefix is configured so CSF's menu loads correctly. + +### Native Wine Component Installer +- Direct-source installation for d3dcompiler, xact, vcrun2022, vcrun2012, and dotnet 6/7/8. Components are downloaded directly from Microsoft/Mozilla and extracted or installed. +- winetricks and protontricks remain as fallback for any component not handled natively. All being well, first step towards removing the hard requirement of protontricks. + +### Install Verification +- After install and configure workflows complete, Jackify verifies that the Wine prefix, Steam shortcut, and Proton compatibility tool are all correctly in place. Results are shown in the success dialog with a breakdown of any issues found. Up to 100 checks are performed depending on the game type and modlist contents. +- Verification can be run on an existing Modlist at any time from the Additional Tools menu. + +### Diagnostics Bundle +- Export a diagnostics bundle (logs, system info, config summary) from Additional Tasks. Attach to bug reports to speed up support. + +### GOG and Epic Games via Heroic +- Both jackify-engine and Jackify should now detect games installed via Heroic Launcher (GOG and Epic), including both native and Flatpak Heroic installations - note that the Modlist itself HAS to support the GOG/Epic version of the base game, and not many do - there is nothing I can do about this. +- GOG/Epic versions of FO3/FNV should now be usable for TTW generation. + +### Fixes +- Configure Existing Modlist now correctly updates `STEAM_COMPAT_MOUNTS` in your Steam shortcut's launch options to include MO2's download_directory so that it is accessible inside MO2. +- Jackify Download Manager manual download columns are now sortable, including numeric sort for file sizes. +- SD card game path detection no longer incorrectly requires a `/Games/` component in the path. +- Download directory should now correctly show the in-Proton path and not the Linux FS path, which was corrupting the download_directory path in MO2. +- Proton version selection now scans installed versions dynamically. Proton 11 (and any future version) is found and ranked correctly. +- Steam grid artwork: if a modlist ships a partial SteamIcons directory, missing slots are now filled from SteamGridDB rather than left blank. +- FO4VR: vcrun2012 applied automatically during tool configuration, as per community suggestion. +- Fallout 4: Creation Kit missing-files error is now detected correctly, rather than stating Skyrim CK is missing. +- Improved cancellation during Wine component installation. Now terminates winetricks and Wine subprocesses immediately rather than waiting for them to finish. +- Fixed: Manual downloads could open the same download link a second time within seconds if the file was still being written to disk when the watcher first detected it. +- Fixed: Files that finished downloading were sometimes not picked up by the watch folder scanner, leaving items stuck on "pending" and requiring manual intervention. +- Added a "Scan Now" button to the manual download dialog to force an immediate re-scan of the watch directory without waiting for the next automatic poll. + +### Engine (0.5.6) +- Heroic Launcher game detection (GOG + Epic, native and Flatpak). +- Fixed case-duplicate archive entries causing hash-mismatch failures on Linux when archives contain files differing only in case. +- Fixed garbled progress lines where tails of longer messages bled through on carriage-return output. + +--- + ## v0.6.0.1 - Hotfix **Release Date:** 24/04/26 @@ -41,7 +93,7 @@ **Release Date:** 29/03/26 - Fixed self-update failing silently due to the downloaded archive overwriting the extraction target before the update helper could apply it. -- Engine updated to 0.5.3. NAME_MAX pre-flight check removed — was incorrectly blocking installs on standard filesystems. eCryptFS/fscrypt users still receive an error at the point of failure. +- Engine updated to 0.5.3. NAME_MAX pre-flight check removed (was incorrectly blocking installs on standard filesystems). eCryptFS/fscrypt users still receive an error at the point of failure. - Fixed Google Drive downloads failing. The Wabbajack CDN proxy was returning a cached broken response for some files; the engine now detects the hash mismatch, retries direct, and constructs a `drive.usercontent.google.com` URL with `confirm=t` to bypass the virus-scan warning page. - Fixed focus stealing from other windows during the Wine component install phase. - Fixed a crash on window close from a leaked focus-reclaim timer. diff --git a/README.md b/README.md index 3dedf02..b589c3c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ # Jackify -Jackify is a Linux application for installing and configuring Wabbajack modlists on Linux and Steam Deck. It provides a complete end-to-end workflow — downloading, installing, Steam shortcut creation, Proton prefix setup, and post-install configuration — through both a GUI and a full-featured CLI. +Jackify is a Linux application for installing and configuring Wabbajack modlists on Linux and Steam Deck. It provides a complete end-to-end workflow (downloading, installing, Steam shortcut creation, Proton prefix setup, and post-install configuration) through both a GUI and a full-featured CLI. ## Features @@ -28,17 +28,17 @@ Jackify is a Linux application for installing and configuring Wabbajack modlists - **Best Effort Support**: Support is provided on a best-effort basis through community channels - **Data Safety**: Always back up your important data before using Jackify - **System Compatibility**: Functionality on your specific system is not guaranteed -- **A successful installation does not guarantee a working modlist**: Linux introduces hardware, driver, and system-specific variables that cannot be accounted for. If your modlist installs successfully but does not run correctly, seek help in [#unofficial-linux-help](https://discord.gg/wabbajack) on the Wabbajack Discord — do not contact the modlist author unless they explicitly support Linux +- **A successful installation does not guarantee a working modlist**: Linux introduces hardware, driver, and system-specific variables that cannot be accounted for. If your modlist installs successfully but does not run correctly, seek help in [#unofficial-linux-help](https://discord.gg/wabbajack) on the Wabbajack Discord. Do not contact the modlist author unless they explicitly support Linux. - **Not all modlists can be fully automated**: Some modlists (e.g. Fallout New Vegas lists) require manual steps that Jackify cannot automate (or I have not automated yet). Always check the Install Guide of the Modlist itself to see what could be needed. - **Most Modlists are not officially supported on Linux**: Jackify makes a best effort to get modlists running, but compatibility is not guaranteed and will vary between modlists, hardware, and system configuration ## Requirements - Linux system (most modern distributions will work) -- Steam installed and configured — **the Snap version of Steam is not supported** -- **Protontricks** — required for modlist configuration +- Steam installed and configured (**the Snap version of Steam is not supported**) +- **Protontricks** (required for modlist configuration) - See [Installing Additional Tools](https://github.com/Omni-guides/Jackify/wiki/Installing-Additional-Tools#installing-protontricks) -- **GE-Proton 10-14** — While other Proton versions may work, GE-Proton 10-14 is highly recommended for ENB compatibility +- **GE-Proton 10-14**: While other Proton versions may work, GE-Proton 10-14 is highly recommended for ENB compatibility - See [Installing Additional Tools](https://github.com/Omni-guides/Jackify/wiki/Installing-Additional-Tools#installing-ge-proton) - **Nexus Mods account** (Premium required for fully automated downloads; Non-Premium supported with manual browser steps) - See the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide) for full details on the options available @@ -77,7 +77,7 @@ For a full step-by-step guide with screenshots, see the [User Guide](https://git - Oblivion - Starfield - Enderal -- Other games (Cyberpunk 2077, Baldur's Gate 3, and more — download and install support only for now - full automatioin coming in the future) +- Other games (Cyberpunk 2077, Baldur's Gate 3, and more): download and install support only for now; full automation coming in the future ## Architecture @@ -106,7 +106,7 @@ The original bash scripts and step-by-step manual installation guides are preser ## License -This project is licensed under the GPLv3 License — see the LICENSE file for details. +This project is licensed under the GPLv3 License. See the LICENSE file for details. ## Support diff --git a/jackify/__init__.py b/jackify/__init__.py index 065161d..90532bd 100644 --- a/jackify/__init__.py +++ b/jackify/__init__.py @@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing Wabbajack modlists natively on Linux systems. """ -__version__ = "0.6.0.1" +__version__ = "0.7.0" diff --git a/jackify/backend/core/modlist_operations_configuration_cli.py b/jackify/backend/core/modlist_operations_configuration_cli.py index f86e2f4..e19cc27 100644 --- a/jackify/backend/core/modlist_operations_configuration_cli.py +++ b/jackify/backend/core/modlist_operations_configuration_cli.py @@ -22,11 +22,64 @@ logger = logging.getLogger(__name__) class ModlistOperationsConfigurationCLIMixin: """Mixin providing CLI configuration phase methods.""" + def _clf3_fetch_wabbajack(self, engine_path: str, machine_name: str, engine_dir: str) -> "str | None": + """ + Resolve a gallery machine name to a local .wabbajack file for CLF3. + + Looks up the CDN download URL from the metadata cache, then runs + `clf3 fetch` to download the file. Returns the local path on success, + or None (with an error printed) on failure. + """ + from jackify.shared.paths import get_jackify_data_dir, get_jackify_downloads_dir + import json as _json + + list_id = machine_name.split('/')[-1] if '/' in machine_name else machine_name + local_path = str(get_jackify_downloads_dir() / f"{list_id}.wabbajack") + + if os.path.isfile(local_path): + self.logger.info("CLF3: using cached wabbajack file at %s", local_path) + return local_path + + download_url = None + cache_file = get_jackify_data_dir() / "modlist-cache" / "metadata" / "modlist_metadata.json" + if cache_file.is_file(): + try: + data = _json.loads(cache_file.read_text(encoding="utf-8")) + for entry in data.get("modlists", []): + if entry.get("namespacedName") == machine_name or entry.get("machineURL") == list_id: + download_url = (entry.get("links") or {}).get("download") + break + except Exception as e: + self.logger.warning("CLF3: could not read metadata cache: %s", e) + + if not download_url: + print( + f"{COLOR_ERROR}CLF3 requires a download URL for '{machine_name}' but none was found in the " + f"gallery cache. Refresh the modlist gallery or use a local .wabbajack file.{COLOR_RESET}" + ) + return None + + print(f"{COLOR_INFO}Downloading modlist file via CLF3...{COLOR_RESET}") + from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env + fetch_cmd = [engine_path, "fetch", download_url, "--output", local_path] + self.logger.debug("CLF3 fetch command: %s", " ".join(fetch_cmd)) + fetch_env = get_clean_subprocess_env({}) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + fetch_result = subprocess.run( + fetch_cmd, capture_output=True, text=True, env=fetch_env, cwd=engine_dir + ) + if fetch_result.returncode != 0: + err = fetch_result.stderr.strip() or fetch_result.stdout.strip() or "unknown error" + print(f"{COLOR_ERROR}Failed to download modlist file:\n{err}{COLOR_RESET}") + return None + + print(f"{COLOR_INFO}Modlist file ready.{COLOR_RESET}") + return local_path + def configuration_phase(self): """ - Run the configuration phase: execute the Linux-native Jackify Install Engine. + Run the configuration phase: execute the active install engine. """ - from .modlist_operations import get_jackify_engine_path print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}") start_time = time.time() @@ -92,11 +145,20 @@ class ModlistOperationsConfigurationCLIMixin: api_key = current_api_key or self.context.get('nexus_api_key') oauth_info = current_oauth_info or self.context.get('nexus_oauth_info') - engine_path = get_jackify_engine_path() - engine_dir = os.path.dirname(engine_path) - if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK): - print(f"{COLOR_ERROR}Jackify Install Engine not found or not executable at: {engine_path}{COLOR_RESET}") + from jackify.backend.services.engine_invoker import ( + get_active_engine_id, get_engine_path, build_install_command, + resolve_game_dir, resolve_game_location, is_clf3_active, + ) + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + + engine_id = get_active_engine_id() + engine_path = get_engine_path(engine_id) + if not engine_path or not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK): + print(f"{COLOR_ERROR}Install engine not found or not executable: {engine_id} ({engine_path or 'path unknown'}){COLOR_RESET}") return + engine_dir = os.path.dirname(engine_path) + clf3_mode = is_clf3_active() if os.environ.get('JACKIFY_GUI_MODE') == '1': if not self.context.get('modlist_source'): @@ -105,60 +167,79 @@ class ModlistOperationsConfigurationCLIMixin: self.logger.error("modlist_value is missing in context for GUI workflow!") return - cmd = [engine_path, 'install', '--show-file-progress'] - modlist_value = self.context.get('modlist_value') - if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value): - cmd += ['-w', modlist_value] - elif modlist_value: - cmd += ['-m', modlist_value] - elif self.context.get('machineid'): - cmd += ['-m', self.context['machineid']] - cmd += ['-o', install_dir_str, '-d', download_dir_str] - - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() + modlist_value = self.context.get('modlist_value') or self.context.get('machineid', '') debug_mode = config_handler.get('debug_mode', False) - if debug_mode: - cmd.append('--debug') + + game_dir = None + if clf3_mode: + game_type = self.context.get('game_type') + location = resolve_game_location(game_type) + if location: + game_dir, game_store = location + if game_store != 'steam': + store_label = {'gog': 'GOG', 'epic': 'Epic Games'}.get(game_store, game_store) + print( + f"[WARN] Game detected from {store_label}, not Steam. " + "Most Wabbajack modlists require the Steam version. " + "If the install fails with hash errors, a store version mismatch is likely the cause." + ) + else: + self.logger.warning("CLF3: could not resolve game directory for game_type=%s", game_type) + + if not (modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value)): + modlist_value = self._clf3_fetch_wabbajack( + engine_path, modlist_value, engine_dir + ) + if modlist_value is None: + return + + cmd = build_install_command( + engine_id=engine_id, + engine_path=engine_path, + wabbajack=modlist_value, + install_dir=install_dir_str, + downloads_dir=download_dir_str, + game_dir=game_dir, + install_mode='file' if (modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value)) else 'online', + debug=debug_mode, + ) + if debug_mode and not clf3_mode: self.logger.info("Adding --debug flag to jackify-engine") - writeback_path = str(auth_service.get_token_writeback_path()) + writeback_path = str(auth_service.get_token_writeback_path()) if not clf3_mode else None original_env_values = { 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), + 'NEXUS_OAUTH_TOKEN': os.environ.get('NEXUS_OAUTH_TOKEN'), 'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'), 'JACKIFY_TOKEN_WRITEBACK': os.environ.get('JACKIFY_TOKEN_WRITEBACK'), 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT') } try: - os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path - if oauth_info: - os.environ['NEXUS_OAUTH_INFO'] = oauth_info - from jackify.backend.services.nexus_oauth_service import NexusOAuthService - os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID - self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)") + if clf3_mode: + if api_key: + os.environ['NEXUS_OAUTH_TOKEN'] = api_key + else: + if writeback_path: + os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path if api_key: os.environ['NEXUS_API_KEY'] = api_key - elif api_key: - os.environ['NEXUS_API_KEY'] = api_key - self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)") - else: - if 'NEXUS_API_KEY' in os.environ: - del os.environ['NEXUS_API_KEY'] - if 'NEXUS_OAUTH_INFO' in os.environ: - del os.environ['NEXUS_OAUTH_INFO'] - if 'NEXUS_OAUTH_CLIENT_ID' in os.environ: - del os.environ['NEXUS_OAUTH_CLIENT_ID'] - self.logger.debug(f"No Nexus auth available, cleared inherited env vars") + if oauth_info: + os.environ['NEXUS_OAUTH_INFO'] = oauth_info + from jackify.backend.services.nexus_oauth_service import NexusOAuthService + os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID + self.logger.debug("Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID for engine") + elif not api_key: + for key in ('NEXUS_API_KEY', 'NEXUS_OAUTH_INFO', 'NEXUS_OAUTH_CLIENT_ID'): + os.environ.pop(key, None) + self.logger.debug("No Nexus auth available, cleared inherited env vars") + os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1" - os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1" - self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.") - - self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.") + self.logger.info("Environment prepared for %s install process.", engine_id) self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}") - self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') else '[NOT SET]'}") pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd]) - print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}") + engine_label = "CLF3" if clf3_mode else "Jackify Install Engine" + print(f"{COLOR_INFO}Launching {engine_label} with command:{COLOR_RESET} {pretty_cmd}") from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit success, old_limit, new_limit, message = increase_file_descriptor_limit() @@ -169,16 +250,80 @@ class ModlistOperationsConfigurationCLIMixin: from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env clean_env = get_clean_subprocess_env() - self._current_process = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=False, - env=clean_env, - cwd=engine_dir, - ) - proc = self._current_process + + if clf3_mode: + import threading as _threading + from jackify.backend.handlers.progress_parser_clf3 import CLF3ProgressStateManager + clf3_parser = CLF3ProgressStateManager() + self._current_process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False, + env=clean_env, + cwd=engine_dir, + ) + proc = self._current_process + + def _drain_stderr(): + for _ in proc.stderr: + pass + + stderr_thread = _threading.Thread(target=_drain_stderr, daemon=True) + stderr_thread.start() + + _inline_active = False + _last_phase = '' + for raw in proc.stdout: + line = raw.decode('utf-8', errors='replace').rstrip('\r\n') + if not line.strip(): + continue + if line.strip().startswith('{'): + prev_phase = clf3_parser.get_state().phase_name + changed = clf3_parser.process_line(line) + if changed: + state = clf3_parser.get_state() + new_phase = state.phase_name or '' + if new_phase != _last_phase: + if _inline_active: + print() + _inline_active = False + _last_phase = new_phase + print(f"\n=== {new_phase} ===") + msg = state.message + if state.phase_name == "Queuing" and state.phase_max_steps: + msg = f"Queuing archives: {state.phase_step}/{state.phase_max_steps}" + if msg: + print(f"\r{msg}\033[K", end='', flush=True) + _inline_active = True + else: + if _inline_active: + print() + _inline_active = False + print(line) + + if _inline_active: + print() + stderr_thread.join(timeout=2) + proc.wait() + self._current_process = None + if proc.returncode != 0: + print(f"{COLOR_ERROR}CLF3 exited with code {proc.returncode}.{COLOR_RESET}") + self.logger.error("CLF3 exited with code %d.", proc.returncode) + return + self.logger.info("CLF3 completed successfully.") + + if not clf3_mode: + self._current_process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=False, + env=clean_env, + cwd=engine_dir, + ) + proc = self._current_process def _write_stdin(payload: str) -> bool: if not proc.stdin or proc.poll() is not None: @@ -282,12 +427,14 @@ class ModlistOperationsConfigurationCLIMixin: proc.wait() self._current_process = None - auth_service.apply_token_writeback(writeback_path) - if proc.returncode != 0: - print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}") - self.logger.error(f"Engine exited with code {proc.returncode}.") - return - self.logger.info(f"Engine completed with code {proc.returncode}.") + if writeback_path: + auth_service.apply_token_writeback(writeback_path) + if not clf3_mode: + if proc.returncode != 0: + print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}") + self.logger.error(f"Engine exited with code {proc.returncode}.") + return + self.logger.info(f"Engine completed with code {proc.returncode}.") except Exception as e: error_message = str(e) print(f"{COLOR_ERROR}Error running Jackify Install Engine: {error_message}{COLOR_RESET}\n") @@ -360,6 +507,28 @@ class ModlistOperationsConfigurationCLIMixin: elapsed = int(time.time() - start_time) print(f"\nElapsed time: {elapsed//3600:02d}:{(elapsed%3600)//60:02d}:{elapsed%60:02d} (hh:mm:ss)\n") print(f"{COLOR_INFO}Your modlist has been installed to: {install_dir_str}{COLOR_RESET}\n") + + try: + from jackify.backend.utils.modlist_meta import write_modlist_meta + _meta_game_type = self.context.get('detected_game') or self.context.get('special_game_type') + write_modlist_meta( + install_dir_str, + self.context.get('modlist_name', ''), + _meta_game_type, + install_mode=self.context.get('install_mode', 'online'), + ) + except Exception as _meta_err: + self.logger.debug("Modlist meta write skipped: %s", _meta_err) + + try: + from jackify.backend.handlers.path_handler import PathHandler + _ini_path = Path(install_dir_str) / "ModOrganizer.ini" + _modlist_sdcard = install_dir_str.startswith('/run/media/') + PathHandler().set_download_directory(_ini_path, download_dir_str, _modlist_sdcard) + self.logger.info("Set download_directory in ModOrganizer.ini: %s", download_dir_str) + except Exception as _ini_err: + self.logger.warning("Could not set download_directory in ModOrganizer.ini: %s", _ini_err) + if self.context.get('machineid') != 'Tuxborn/Tuxborn': print(f"{COLOR_WARNING}Only Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, and Oblivion Remastered modlists are compatible with Jackify's post-install configuration. Any modlist can be downloaded/installed, but only these games are supported for automated configuration.{COLOR_RESET}") @@ -500,8 +669,11 @@ class ModlistOperationsConfigurationCLIMixin: _is_steamdeck = True except Exception: _is_steamdeck = False + from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir + download_dir = resolve_mo2_download_dir(Path(install_dir_str)) result = prefix_service.run_working_workflow( - shortcut_name, install_dir_str, mo2_exe_path, progress_callback, steamdeck=_is_steamdeck + shortcut_name, install_dir_str, mo2_exe_path, progress_callback, + steamdeck=_is_steamdeck, download_dir=download_dir, ) if isinstance(result, tuple) and len(result) == 4: @@ -642,7 +814,7 @@ class ModlistOperationsConfigurationCLIMixin: modlist_context = ModlistContext( name=shortcut_name, install_dir=Path(install_dir_str), - download_dir=Path(install_dir_str) / "downloads", + download_dir=Path(download_dir_str), game_type=self.context.get('detected_game', 'Unknown'), nexus_api_key='', modlist_value=self.context.get('modlist_value', ''), @@ -669,6 +841,21 @@ class ModlistOperationsConfigurationCLIMixin: if configuration_success: self.logger.info("Post-installation configuration completed successfully") print(f"{COLOR_INFO}Core configuration complete. Checking post-install automation...{COLOR_RESET}") + + if getattr(modlist_context, 'enb_detected', False): + print(f"\n{COLOR_WARNING}ENB Detected{COLOR_RESET}") + from jackify.backend.data.modlist_proton_requirements import get_proton_requirement + _proton_req = get_proton_requirement(shortcut_name) + if _proton_req: + print(f"{COLOR_WARNING}This modlist requires {_proton_req['required']} for ENB compatibility.{COLOR_RESET}") + print(f"{COLOR_INFO}{_proton_req['note']}{COLOR_RESET}") + else: + print(f"{COLOR_INFO}If you plan on using ENB as part of this modlist, you will need one of the following Proton versions:{COLOR_RESET}") + print(f"{COLOR_INFO} (In order of recommendation){COLOR_RESET}") + print(f"{COLOR_INFO} - Proton-CachyOS{COLOR_RESET}") + print(f"{COLOR_INFO} - GE-Proton{COLOR_RESET}") + print(f"{COLOR_INFO} - Proton 9 (Valve){COLOR_RESET}") + print(f"{COLOR_WARNING} Valve Proton 10 has known ENB compatibility issues.{COLOR_RESET}") try: # Ensure CLI install flow gets the same VNV automation behavior as GUI. from jackify.backend.services.vnv_integration_helper import ( @@ -747,7 +934,83 @@ class ModlistOperationsConfigurationCLIMixin: except Exception as ttw_err: self.logger.error("TTW post-install prompt failed: %s", ttw_err, exc_info=True) print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}") - print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}") + try: + from jackify.backend.handlers.modlist_fixup_handler import ( + check_jcontainers_needs_fix, + apply_jcontainers_fix, + ) + _jc_game_type = detected_game or self.context.get('detected_game', '') + needs_fix = check_jcontainers_needs_fix(Path(install_dir_str), _jc_game_type) + if needs_fix: + print(f"\n{COLOR_WARNING}JContainers Compatibility Fix{COLOR_RESET}") + print(f"{COLOR_INFO}The mod JContainers has been detected. The Nexusmods version is known to cause crashes on Linux/Proton.{COLOR_RESET}") + print(f"{COLOR_INFO}A fixed version is available from the mod's GitHub page. The original DLL will be backed up first.{COLOR_RESET}") + try: + user_input = input(f"{COLOR_PROMPT}Apply JContainers fix now? (Y/n): {COLOR_RESET}").strip().lower() + except (EOFError, KeyboardInterrupt): + user_input = "n" + if user_input in ("", "y", "yes"): + apply_jcontainers_fix(Path(install_dir_str), _jc_game_type) + print(f"{COLOR_INFO}JContainers fix applied.{COLOR_RESET}") + else: + print(f"{COLOR_INFO}JContainers fix skipped.{COLOR_RESET}") + except Exception as jc_err: + self.logger.warning("JContainers fix check failed (non-fatal): %s", jc_err) + try: + from jackify.backend.services.install_verifier_service import ( + run_install_verification, resolve_pfx_for_appid, _load_verifier as _lv_final, + ) + from jackify.frontends.cli.ui.indeterminate_status import CliIndeterminateStatus + import threading + _pfx = resolve_pfx_for_appid(str(app_id)) if app_id else None + if _pfx and _pfx.is_dir(): + _vmod_final = _lv_final() + _norm_gt = _vmod_final.detect_game_type(Path(install_dir_str)) + _verif_result = [None] + _spinner = CliIndeterminateStatus() + _spinner.set("Running install verification...") + def _verif_worker(): + _verif_result[0] = run_install_verification( + _pfx, + Path(install_dir_str), + _norm_gt, + str(app_id) if app_id else "", + shortcut_name, + ) + _t = threading.Thread(target=_verif_worker, daemon=True) + _t.start() + _t.join() + _spinner.stop() + r = _verif_result[0] + if r is not None: + n_pass = len(r.passes) if hasattr(r, 'passes') else 0 + n_warn = len(r.warnings) if hasattr(r, 'warnings') else 0 + n_fail = len(r.failures) if hasattr(r, 'failures') else 0 + _total = n_pass + n_warn + n_fail + print(f"\n--- Install Verification ---") + print(f" {n_pass} passed, {n_warn} warnings, {n_fail} failed (of {_total} checks)") + for msg in (r.failures if hasattr(r, 'failures') else []): + print(f"{COLOR_ERROR} [FAIL] {msg}{COLOR_RESET}") + for msg in (r.warnings if hasattr(r, 'warnings') else []): + print(f"{COLOR_WARNING} [WARN] {msg}{COLOR_RESET}") + if not n_fail and not n_warn: + print(f"{COLOR_SUCCESS} All checks passed.{COLOR_RESET}") + print() + except Exception as verif_err: + print(f"{COLOR_WARNING}[WARN] Install verifier failed: {verif_err}{COLOR_RESET}") + self.logger.warning("Install verification failed: %s", verif_err, exc_info=True) + from jackify.shared.paths import get_jackify_logs_dir + print("") + print("") + print("=" * 35) + print("= Configuration phase complete =") + print("=" * 35) + print("") + print("Modlist Install and Configuration complete!") + print(f" You should now be able to Launch '{shortcut_name}' through Steam") + print(" Congratulations and enjoy the game!") + print("") + print(f"Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log") else: print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}") self.logger.warning("Post-installation configuration had issues") diff --git a/jackify/backend/core/modlist_operations_configuration_gui.py b/jackify/backend/core/modlist_operations_configuration_gui.py index 1e45add..dde265b 100644 --- a/jackify/backend/core/modlist_operations_configuration_gui.py +++ b/jackify/backend/core/modlist_operations_configuration_gui.py @@ -10,23 +10,16 @@ class ModlistOperationsConfigurationGUIMixin: def configuration_phase_gui_mode(self, context, progress_callback=None, - manual_steps_callback=None, completion_callback=None): """ GUI-friendly configuration phase that uses callbacks instead of prompts. - This method provides the same functionality as configuration_phase() but - integrates with GUI frontends using Qt callbacks instead of CLI prompts. - Args: context: Configuration context dict with modlist details progress_callback: Called with progress messages (str) - manual_steps_callback: Called when manual steps needed (modlist_name, retry_count) completion_callback: Called when configuration completes (success, message, modlist_name) """ try: - from .modlist_operations import _get_user_proton_version - original_gui_mode = os.environ.get('JACKIFY_GUI_MODE') try: @@ -38,121 +31,27 @@ class ModlistOperationsConfigurationGUIMixin: 'modlist_source': context.get('modlist_source'), 'resolution': context.get('resolution'), 'skip_confirmation': True, - 'manual_steps_completed': False } existing_app_id = context.get('app_id') if existing_app_id: config_context['appid'] = existing_app_id - if progress_callback: - progress_callback(f"Configuring existing modlist with AppID {existing_app_id}...") + if progress_callback: + progress_callback("Running modlist configuration...") - from jackify.backend.handlers.menu_handler import ModlistMenuHandler - from jackify.backend.handlers.config_handler import ConfigHandler + from jackify.backend.handlers.menu_handler import ModlistMenuHandler + from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - modlist_menu = ModlistMenuHandler(config_handler) - - retry_count = 0 - max_retries = 3 - - while retry_count < max_retries: - if progress_callback: - progress_callback("Running modlist configuration...") - - result = modlist_menu.run_modlist_configuration_phase(config_context) - - if progress_callback: - progress_callback(f"Configuration attempt {retry_count}: {'Success' if result else 'Failed'}") - - if result: - if completion_callback: - completion_callback(True, "Core configuration complete", config_context['name']) - return True - else: - retry_count += 1 - - if retry_count < max_retries: - if progress_callback: - progress_callback(f"Configuration failed on attempt {retry_count}, showing manual steps dialog...") - if manual_steps_callback: - if progress_callback: - progress_callback(f"Calling manual_steps_callback for {config_context['name']}, retry {retry_count}") - manual_steps_callback(config_context['name'], retry_count) - - config_context['manual_steps_completed'] = True - else: - if completion_callback: - completion_callback(False, "Manual steps failed after multiple attempts", config_context['name']) - return False + config_handler = ConfigHandler() + modlist_menu = ModlistMenuHandler(config_handler) + result = modlist_menu.run_modlist_configuration_phase(config_context) + if result: if completion_callback: - completion_callback(False, "Configuration failed", config_context['name']) - return False - + completion_callback(True, "Core configuration complete", config_context['name']) + return True else: - from jackify.backend.handlers.menu_handler import ModlistMenuHandler - from jackify.backend.handlers.config_handler import ConfigHandler - - config_handler = ConfigHandler() - modlist_menu = ModlistMenuHandler(config_handler) - - if progress_callback: - progress_callback("Creating Steam shortcut...") - - from jackify.backend.services.native_steam_service import NativeSteamService - steam_service = NativeSteamService() - - proton_version = _get_user_proton_version() - - success, app_id = steam_service.create_shortcut_with_proton( - app_name=config_context['name'], - exe_path=config_context['mo2_exe_path'], - start_dir=os.path.dirname(config_context['mo2_exe_path']), - launch_options="%command%", - tags=["Jackify"], - proton_version=proton_version - ) - - if not success or not app_id: - if completion_callback: - completion_callback(False, "Failed to create Steam shortcut", config_context['name']) - return False - - config_context['appid'] = app_id - - if progress_callback: - from jackify.shared.timing import get_timestamp - progress_callback(f"{get_timestamp()} Steam shortcut created successfully") - - if progress_callback: - progress_callback("Running modlist configuration...") - - if progress_callback: - progress_callback(f"About to call run_modlist_configuration_phase with context: {config_context}") - - result = modlist_menu.run_modlist_configuration_phase(config_context) - - if progress_callback: - progress_callback(f"run_modlist_configuration_phase returned: {result}") - - if result: - if completion_callback: - completion_callback(True, "Core configuration complete", config_context['name']) - return True - else: - if progress_callback: - progress_callback("Configuration failed, manual Steam/Proton setup required") - if manual_steps_callback: - if progress_callback: - progress_callback(f"About to call manual_steps_callback for {config_context['name']}, retry 1") - manual_steps_callback(config_context['name'], 1) - if progress_callback: - progress_callback("manual_steps_callback completed") - - return True - if completion_callback: completion_callback(False, "Configuration failed", config_context['name']) return False diff --git a/jackify/backend/data/dll_overrides.reg b/jackify/backend/data/dll_overrides.reg new file mode 100644 index 0000000..64aa896 --- /dev/null +++ b/jackify/backend/data/dll_overrides.reg @@ -0,0 +1,77 @@ +Windows Registry Editor Version 5.00 + +[HKEY_CURRENT_USER\Software\Wine\DllOverrides] +"d3dcompiler_43"="native" +"d3dcompiler_47"="native" +"d3dx9_24"="native" +"d3dx9_25"="native" +"d3dx9_26"="native" +"d3dx9_27"="native" +"d3dx9_28"="native" +"d3dx9_29"="native" +"d3dx9_30"="native" +"d3dx9_31"="native" +"d3dx9_32"="native" +"d3dx9_33"="native" +"d3dx9_34"="native" +"d3dx9_35"="native" +"d3dx9_36"="native" +"d3dx9_37"="native" +"d3dx9_38"="native" +"d3dx9_39"="native" +"d3dx9_40"="native" +"d3dx9_41"="native" +"d3dx9_42"="native" +"d3dx9_43"="native" +"d3dx11_43"="native" +"xaudio2_0"="native,builtin" +"xaudio2_1"="native,builtin" +"xaudio2_2"="native,builtin" +"xaudio2_3"="native,builtin" +"xaudio2_4"="native,builtin" +"xaudio2_5"="native,builtin" +"xaudio2_6"="native,builtin" +"xaudio2_7"="native,builtin" +"x3daudio1_0"="native,builtin" +"x3daudio1_1"="native,builtin" +"x3daudio1_2"="native,builtin" +"x3daudio1_3"="native,builtin" +"x3daudio1_4"="native,builtin" +"x3daudio1_5"="native,builtin" +"x3daudio1_6"="native,builtin" +"x3daudio1_7"="native,builtin" +"xapofx1_1"="native,builtin" +"xapofx1_2"="native,builtin" +"xapofx1_3"="native,builtin" +"xapofx1_4"="native,builtin" +"xapofx1_5"="native,builtin" +"xactengine2_0"="native,builtin" +"xactengine2_1"="native,builtin" +"xactengine2_2"="native,builtin" +"xactengine2_3"="native,builtin" +"xactengine2_4"="native,builtin" +"xactengine2_5"="native,builtin" +"xactengine2_6"="native,builtin" +"xactengine2_7"="native,builtin" +"xactengine2_8"="native,builtin" +"xactengine2_9"="native,builtin" +"xactengine2_10"="native,builtin" +"xactengine3_0"="native,builtin" +"xactengine3_1"="native,builtin" +"xactengine3_2"="native,builtin" +"xactengine3_3"="native,builtin" +"xactengine3_4"="native,builtin" +"xactengine3_5"="native,builtin" +"xactengine3_6"="native,builtin" +"xactengine3_7"="native,builtin" +"concrt140"="native,builtin" +"msvcp140"="native,builtin" +"msvcp140_1"="native,builtin" +"msvcp140_2"="native,builtin" +"msvcp140_atomic_wait"="native,builtin" +"msvcp140_codecvt_ids"="native,builtin" +"vcamp140"="native,builtin" +"vccorlib140"="native,builtin" +"vcomp140"="native,builtin" +"vcruntime140"="native,builtin" +"vcruntime140_1"="native,builtin" diff --git a/jackify/backend/data/modlist_proton_requirements.py b/jackify/backend/data/modlist_proton_requirements.py new file mode 100644 index 0000000..fded86d --- /dev/null +++ b/jackify/backend/data/modlist_proton_requirements.py @@ -0,0 +1,19 @@ +"""Per-modlist Proton version requirements for ENB compatibility warnings.""" + +from typing import Optional + +# Keys are lowercase modlist names (matched case-insensitively). +# required: specific Proton build known to work +# note: shown to the user alongside the requirement +MODLIST_PROTON_REQUIREMENTS: dict[str, dict[str, str]] = { + "lorerim": { + "required": "GE-Proton10-34", + "note": "Proton-CachyOS 11 and Valve Proton are known to not work with this list.", + }, +} + + +def get_proton_requirement(modlist_name: str) -> Optional[dict[str, str]]: + if not modlist_name: + return None + return MODLIST_PROTON_REQUIREMENTS.get(modlist_name.strip().lower()) diff --git a/jackify/backend/data/native_components_versions.json b/jackify/backend/data/native_components_versions.json new file mode 100644 index 0000000..b44e75b --- /dev/null +++ b/jackify/backend/data/native_components_versions.json @@ -0,0 +1,57 @@ +{ + "_comment": "Version-pinned URLs and COM data for native component installer. Update per release.", + "dotnet6": { + "x86_zip_url": "https://dotnetcli.azureedge.net/dotnet/Runtime/6.0.36/dotnet-runtime-6.0.36-win-x86.zip", + "x64_zip_url": "https://dotnetcli.azureedge.net/dotnet/Runtime/6.0.36/dotnet-runtime-6.0.36-win-x64.zip" + }, + "dotnet7": { + "x86_zip_url": "https://dotnetcli.azureedge.net/dotnet/Runtime/7.0.20/dotnet-runtime-7.0.20-win-x86.zip", + "x64_zip_url": "https://dotnetcli.azureedge.net/dotnet/Runtime/7.0.20/dotnet-runtime-7.0.20-win-x64.zip" + }, + "dotnet8": { + "x86_zip_url": "https://dotnetcli.azureedge.net/dotnet/Runtime/8.0.12/dotnet-runtime-8.0.12-win-x86.zip", + "x64_zip_url": "https://dotnetcli.azureedge.net/dotnet/Runtime/8.0.12/dotnet-runtime-8.0.12-win-x64.zip" + }, + "dotnet9": { + "x86_zip_url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.16/dotnet-runtime-9.0.16-win-x86.zip", + "x64_zip_url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.16/dotnet-runtime-9.0.16-win-x64.zip" + }, + "dotnet10": { + "x86_zip_url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/10.0.8/dotnet-runtime-10.0.8-win-x86.zip", + "x64_zip_url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/10.0.8/dotnet-runtime-10.0.8-win-x64.zip" + }, + "dotnetdesktop6": { + "x86_zip_url": "https://dotnetcli.azureedge.net/dotnet/WindowsDesktop/6.0.36/windowsdesktop-runtime-6.0.36-win-x86.zip", + "x64_zip_url": "https://dotnetcli.azureedge.net/dotnet/WindowsDesktop/6.0.36/windowsdesktop-runtime-6.0.36-win-x64.zip" + }, + "dotnetdesktop9": { + "x86_zip_url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/9.0.16/windowsdesktop-runtime-9.0.16-win-x86.zip", + "x64_zip_url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/9.0.16/windowsdesktop-runtime-9.0.16-win-x64.zip" + }, + "dotnetdesktop10": { + "x86_zip_url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.8/windowsdesktop-runtime-10.0.8-win-x86.zip", + "x64_zip_url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.8/windowsdesktop-runtime-10.0.8-win-x64.zip" + }, + "xact_clsids": { + "xactengine2_0.dll": ["{0AA000AA-F404-11D9-BD7A-0010DC4F8F81}"], + "xactengine2_4.dll": ["{BC3E0FC6-2E0D-4C45-BC61-D9C328319BD8}"], + "xactengine2_7.dll": ["{CD0D66EC-8057-43F5-ACBD-66DFB36FD78C}"], + "xactengine2_9.dll": ["{343E68E6-8F82-4A8D-A2DA-6E9A944B378C}"], + "xactengine3_0.dll": ["{3B80EE2A-B0F5-4780-9E30-90CB39685B03}"], + "xactengine3_1.dll": ["{962F5027-99BE-4692-A468-85802CF8DE61}"], + "xactengine3_2.dll": ["{D3332F02-3DD0-4DE9-9AEC-20D85C4111B6}"], + "xactengine3_3.dll": ["{94C1AFFA-66E7-4961-9521-CFDEF3128D4F}"], + "xactengine3_4.dll": ["{0977D092-2D95-4E43-8D42-9DDCC2545ED5}"], + "xactengine3_5.dll": ["{074B110F-7F58-4743-AEA5-12F15B5074ED}"], + "xactengine3_6.dll": ["{248D8A3B-6256-44D3-A018-2AC96C459F47}"], + "xactengine3_7.dll": ["{BCC782BC-6492-4C22-8C35-F5D72FE73C6E}"], + "xaudio2_0.dll": ["{FAC23F48-31F5-45A8-B49B-5225D61401AA}", "{6F6EA3A9-2CF5-41CF-91C1-2170B1540063}", "{C0C56F46-29B1-44E9-9939-A32CE86867E2}"], + "xaudio2_1.dll": ["{E21A7345-EB21-468E-BE50-804DB97CF708}", "{F4769300-B949-4DF9-B333-00D33932E9A6}", "{C1E3F122-A2EA-442C-854F-20D98F8357A1}"], + "xaudio2_2.dll": ["{B802058A-464A-42DB-BC10-B650D6F2586A}", "{629CF0DE-3ECC-41E7-9926-F7E43EEBEC51}", "{F5CA7B34-8055-42C0-B836-216129EB7E30}"], + "xaudio2_3.dll": ["{4C5E637A-16C7-4DE3-9C46-5ED22181962D}", "{9CAB402C-1D37-44B4-886D-FA4F36170A4C}", "{E180344B-AC83-4483-959E-18A5C56A5E19}"], + "xaudio2_4.dll": ["{03219E78-5BC3-44D1-B92E-F63D89CC6526}", "{8BB7778B-645B-4475-9A73-1DE3170BD3AF}", "{C7338B95-52B8-4542-AA79-42EB016C8C1C}"], + "xaudio2_5.dll": ["{4C9B6DDE-6809-46E6-A278-9B6A97588670}", "{D06DF0D0-8518-441E-822F-5451D5C595B8}", "{2139E6DA-C341-4774-9AC3-B4E026347F64}"], + "xaudio2_6.dll": ["{3EDA9B49-2085-498B-9BB2-39A6778493DE}", "{CECEC95A-D894-491A-BEE3-5E106FB59F2D}", "{E48C5A3F-93EF-43BB-A092-2C7CEB946F27}"], + "xaudio2_7.dll": ["{5A508685-A254-4FBA-9B82-9A24B00306AF}", "{6A93130E-1D53-41D1-A9CF-E758800BB179}", "{CAC1105F-619B-4D04-831A-44E1CBF12D57}"] + } +} diff --git a/jackify/backend/handlers/config_handler.py b/jackify/backend/handlers/config_handler.py index 00c297d..766a118 100644 --- a/jackify/backend/handlers/config_handler.py +++ b/jackify/backend/handlers/config_handler.py @@ -220,10 +220,21 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM def save_config(self): """Save current configuration to file""" + import tempfile try: self._create_config_dir() - with open(self.config_file, 'w') as f: - json.dump(self.settings, f, indent=2) + fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(self.config_file), prefix='.config_tmp_') + try: + with os.fdopen(fd, 'w') as f: + json.dump(self.settings, f, indent=2) + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, self.config_file) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise logger.debug("Saved configuration to file") return True except Exception as e: diff --git a/jackify/backend/handlers/config_handler_encryption.py b/jackify/backend/handlers/config_handler_encryption.py index 6f15955..6d60360 100644 --- a/jackify/backend/handlers/config_handler_encryption.py +++ b/jackify/backend/handlers/config_handler_encryption.py @@ -98,13 +98,7 @@ class ConfigEncryptionMixin: else: self.settings["nexus_api_key"] = None logger.debug("API key cleared") - result = self.save_config() - if result: - try: - os.chmod(self.config_file, 0o600) - except Exception as e: - logger.warning("Could not set restrictive permissions on config: %s", e) - return result + return self.save_config() except Exception as e: logger.error("Error saving API key: %s", e) return False diff --git a/jackify/backend/handlers/filesystem_handler.py b/jackify/backend/handlers/filesystem_handler.py index 7ede31b..a4f8561 100644 --- a/jackify/backend/handlers/filesystem_handler.py +++ b/jackify/backend/handlers/filesystem_handler.py @@ -384,31 +384,7 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files logger.error(f"Failed to add backupPath entry to {modlist_ini}: {e}") return False # Backup succeeded, but adding entry failed - def blank_downloads_dir(self, modlist_ini: Path) -> bool: - """ - Blank or reset the MO2 Downloads Directory - Returns True on success, False on failure - """ - try: - self.logger.info("Editing download_directory...") - - # Read the file - with open(modlist_ini, 'r') as f: - content = f.read() - - # Replace the download_directory line - modified_content = re.sub(r'download_directory[^\n]*', 'download_directory =', content) - - # Write back to the file - with open(modlist_ini, 'w') as f: - f.write(modified_content) - - self.logger.debug("Download directory cleared successfully") - return True - - except Exception as e: - self.logger.error(f"Error blanking downloads directory: {e}") - return False + def copy_file(self, src: Path, dst: Path, overwrite: bool = False) -> bool: """ diff --git a/jackify/backend/handlers/filesystem_handler_ownership.py b/jackify/backend/handlers/filesystem_handler_ownership.py index 5c28182..2a74939 100644 --- a/jackify/backend/handlers/filesystem_handler_ownership.py +++ b/jackify/backend/handlers/filesystem_handler_ownership.py @@ -38,6 +38,11 @@ class FilesystemOwnershipMixin: Verify and fix ownership/permissions for modlist directory. Returns (success, error_message). """ + from jackify.backend.handlers.validation_handler import ValidationHandler + if ValidationHandler().is_dangerous_directory(path): + logger.error("Refusing to modify permissions on dangerous path: %s", path) + return False, f"Refusing to modify permissions on '{path}': system or user root directory." + if not path.exists(): logger.error("Path does not exist: %s", path) return False, f"Path does not exist: {path}" diff --git a/jackify/backend/handlers/menu_handler_modlist.py b/jackify/backend/handlers/menu_handler_modlist.py index 9465be2..f7135da 100644 --- a/jackify/backend/handlers/menu_handler_modlist.py +++ b/jackify/backend/handlers/menu_handler_modlist.py @@ -280,8 +280,11 @@ class ModlistMenuHandler: print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}") while True: + from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir + download_dir = resolve_mo2_download_dir(Path(install_dir)) result = prefix_service.run_working_workflow( - modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck + modlist_name, install_dir, mo2_path, progress_callback, + steamdeck=self.steamdeck, download_dir=download_dir, ) if isinstance(result, tuple) and len(result) == 4: @@ -303,7 +306,6 @@ class ModlistMenuHandler: "name": modlist_name, "appid": str(existing_appid), "path": mo2_dir, - "manual_steps_completed": True, "resolution": None } return self.run_modlist_configuration_phase(context) @@ -335,7 +337,6 @@ class ModlistMenuHandler: "name": modlist_name, "appid": str(appid_int), "path": mo2_dir, - "manual_steps_completed": True, "resolution": None } self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}") @@ -534,9 +535,7 @@ class ModlistMenuHandler: else: status_line = f"\r{COLOR_INFO}{msg}{COLOR_RESET}" print(status_line, end="", flush=True) - manual_steps_completed = context.get("manual_steps_completed", False) - skip_manual_for_existing = context.get("modlist_source") == "existing" # Existing modlists skip manual steps - if not self.modlist_handler._execute_configuration_steps(status_callback=update_status, manual_steps_completed=manual_steps_completed, skip_manual_for_existing=skip_manual_for_existing): + if not self.modlist_handler._execute_configuration_steps(status_callback=update_status): if status_line: print() self.logger.error(f"Core configuration steps failed for {context.get('name')}") @@ -555,7 +554,6 @@ class ModlistMenuHandler: if not gui_mode: try: from ..handlers.enb_handler import ENBHandler - from pathlib import Path enb_handler = ENBHandler() install_dir = Path(context.get('path', '')) @@ -587,8 +585,6 @@ class ModlistMenuHandler: create_vnv_cli_progress_callback, ensure_vnv_cli_manual_downloads, ) - from pathlib import Path - modlist_name = context.get('name', '') modlist_path = Path(context.get('path', '')) @@ -660,37 +656,57 @@ class ModlistMenuHandler: completion_title = "Modlist Configuration complete!" if is_existing_flow else "Modlist Install and Configuration complete!" completion_log_file = "Configure_Existing_Modlist_workflow.log" if is_existing_flow else "Configure_New_Modlist_workflow.log" - print("") - print("") - print("") # Extra blank line before completion - print("=" * 35) - print("= Configuration phase complete =") - print("=" * 35) - print("") - print(completion_title) - print(f"• You should now be able to Launch '{context.get('name')}' through Steam") - print("• Congratulations and enjoy the game!") - print("") + if not context.get('suppress_completion_banner'): + print("") + print("") + print("") + print("=" * 35) + print("= Configuration phase complete =") + print("=" * 35) + print("") + print(completion_title) + print(f"• You should now be able to Launch '{context.get('name')}' through Steam") + print("• Congratulations and enjoy the game!") + print("") - # Show ENB-specific warning if ENB was detected (replaces generic note) - if enb_detected: - print(f"{COLOR_WARNING}ENB DETECTED{COLOR_RESET}") - print("") - print("If you plan on using ENB as part of this modlist, you will need to use") - print("one of the following Proton versions, otherwise you will have issues:") - print("") - print(" (in order of recommendation)") - print(f" {COLOR_SUCCESS}• Proton-CachyOS{COLOR_RESET}") - print(f" {COLOR_INFO}• GE-Proton 10-14 or lower{COLOR_RESET}") - print(f" {COLOR_WARNING}• Proton 9 from Valve{COLOR_RESET}") - print("") - print(f"{COLOR_WARNING}Note: Valve's Proton 10 has known ENB compatibility issues.{COLOR_RESET}") - print("") - else: - # No ENB detected - no warning needed - pass - from jackify.shared.paths import get_jackify_logs_dir - print(f"Detailed log available at: {get_jackify_logs_dir()}/{completion_log_file}") + if not context.get('suppress_completion_banner'): + if enb_detected: + print(f"{COLOR_WARNING}ENB DETECTED{COLOR_RESET}") + print("") + print("If you plan on using ENB as part of this modlist, you will need to use") + print("one of the following Proton versions, otherwise you will have issues:") + print("") + print(" (in order of recommendation)") + print(f" {COLOR_SUCCESS}• Proton-CachyOS{COLOR_RESET}") + print(f" {COLOR_INFO}• GE-Proton{COLOR_RESET}") + print(f" {COLOR_WARNING}• Proton 9 from Valve{COLOR_RESET}") + print("") + print(f"{COLOR_WARNING}Note: Valve's Proton 10 has known ENB compatibility issues.{COLOR_RESET}") + print("") + from jackify.shared.paths import get_jackify_logs_dir + print(f"Detailed log available at: {get_jackify_logs_dir()}/{completion_log_file}") + + try: + install_path = context.get('path') + shortcut_name = context.get('name') + mo2_exe = context.get('mo2_exe_path') or ( + os.path.join(install_path, 'ModOrganizer.exe') if install_path else None + ) + if install_path and shortcut_name and mo2_exe: + ini_path = Path(install_path) / 'ModOrganizer.ini' + dl_str = self.path_handler.get_download_directory_linux_path(ini_path) + if dl_str: + mounts_result = self.shortcut_handler.ensure_mounts_in_steam_compat( + shortcut_name, mo2_exe, dl_str + ) + if mounts_result in ("updated", "steam_running"): + context['steam_restart_needed'] = True + context['mounts_app_name'] = shortcut_name + context['mounts_exe_path'] = mo2_exe + context['mounts_dl_path'] = dl_str + except Exception as e: + self.logger.error("Could not update STEAM_COMPAT_MOUNTS: %s", e, exc_info=True) + # Only wait for input in CLI mode, not GUI mode if not gui_mode: input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}") diff --git a/jackify/backend/handlers/modlist_configuration.py b/jackify/backend/handlers/modlist_configuration.py index a1e98bf..65659cb 100644 --- a/jackify/backend/handlers/modlist_configuration.py +++ b/jackify/backend/handlers/modlist_configuration.py @@ -48,13 +48,11 @@ class ModlistConfigurationMixin: else: return True - def _execute_configuration_steps(self, status_callback=None, manual_steps_completed=False, skip_manual_for_existing=False): + def _execute_configuration_steps(self, status_callback=None): """ Runs the actual configuration steps for the selected modlist. Args: status_callback (callable, optional): A function to call with status updates during configuration. - manual_steps_completed (bool): If True, skip the manual steps prompt (used for new modlist flow). - skip_manual_for_existing (bool): If True, always skip manual steps (for existing modlists that are already configured). """ try: # Store status_callback for Configuration Summary @@ -88,64 +86,6 @@ class ModlistConfigurationMixin: return False # Abort on failure self.logger.info("Step 1: Setting Protontricks permissions... Done") - # Step 2: Prompt user for manual steps and wait for compatdata - skip_manual_prompt = skip_manual_for_existing # Existing modlists skip manual steps - if not manual_steps_completed and not skip_manual_for_existing: - # Check if Proton Experimental is already set and compatdata exists - proton_ok = False - compatdata_ok = False - - # Check Proton version - self.logger.debug(f"[MANUAL STEPS DEBUG] Checking Proton version for AppID {self.appid}") - if self._detect_proton_version(): - self.logger.debug(f"[MANUAL STEPS DEBUG] Detected Proton version: {self.proton_ver}") - if self.proton_ver and 'experimental' in self.proton_ver.lower(): - proton_ok = True - self.logger.debug("[MANUAL STEPS DEBUG] Proton Experimental detected - proton_ok = True") - else: - self.logger.debug("[MANUAL STEPS DEBUG] Could not detect Proton version") - - # Check compatdata/prefix - prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) - self.logger.debug(f"[MANUAL STEPS DEBUG] Compatdata path search result: {prefix_path_str}") - - if prefix_path_str and os.path.isdir(prefix_path_str): - compatdata_ok = True - self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory exists - compatdata_ok = True") - else: - self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory does not exist") - - self.logger.debug(f"[MANUAL STEPS DEBUG] proton_ok: {proton_ok}, compatdata_ok: {compatdata_ok}") - - if proton_ok and compatdata_ok: - self.logger.info("Proton Experimental and compatdata already set for this AppID; skipping manual steps prompt.") - skip_manual_prompt = True - else: - self.logger.debug("[MANUAL STEPS DEBUG] Manual steps will be required") - - self.logger.debug(f"[MANUAL STEPS DEBUG] manual_steps_completed: {manual_steps_completed}, skip_manual_prompt: {skip_manual_prompt}") - - if not manual_steps_completed and not skip_manual_prompt: - # Check if we're in GUI mode - if so, don't show CLI prompts, just fail and let GUI callbacks handle it - gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' - - if gui_mode: - # In GUI mode: don't show CLI prompts, just fail so GUI can show dialog and retry - self.logger.info("GUI mode detected: skipping CLI manual steps prompt, will fail configuration to trigger GUI callback") - if status_callback: - status_callback("Manual Steam/Proton setup required - this will be handled by GUI dialog") - # Return False to trigger manual steps callback in GUI - return False - else: - # CLI mode: show the traditional CLI prompt - if status_callback: - status_callback("Please perform the manual steps in Steam (set Proton, launch shortcut, then close MO2)...") - self.logger.info("Prompting user to perform manual Steam/Proton steps and launch shortcut.") - print("\n───────────────────────────────────────────────────────────────────") - print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} Please follow the on-screen instructions to set Proton Experimental and launch the shortcut from Steam.") - print("───────────────────────────────────────────────────────────────────") - input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") - self.logger.info("User confirmed completion of manual steps.") # Step 3: Apply targeted registry tweaks (replaces wholesale curated reg file overwrite) if status_callback: status_callback(f"{self._get_progress_timestamp()} Applying modlist registry configuration") @@ -162,7 +102,31 @@ class ModlistConfigurationMixin: # Use canonical logic for all modlists/games components = self.get_modlist_wine_components(self.game_name, self.game_var_full) - + + # NSF detection: dotnet48 required for Skyrim SE modlists with NetScriptFramework + if 'skyrim' in (self.game_var_full or '').lower() and self.modlist_dir: + nsf_markers = [ + Path(self.modlist_dir) / 'mods' / 'NetScriptFramework' / 'DLLPlugins' / 'NetScriptFramework.Runtime.dll', + Path(self.modlist_dir) / 'mods' / 'NetScriptFramework' / 'Plugins' / 'CustomSkills.dll', + Path(self.modlist_dir) / 'mods' / 'NetScriptFramework', + ] + if any(m.exists() for m in nsf_markers): + self._nsf_detected = True + if 'dotnet48' not in components: + self.logger.info("NetScriptFramework detected, adding dotnet48 to component list") + components.insert(0, 'dotnet48') + + # Developer/testing override: JACKIFY_SKIP_WINE_COMPONENTS=comp1,comp2 drops those + # components from this run, for iterating without re-installing slow verbs (e.g. + # dotnetdesktop6). Unset in normal use, so it has no effect on real installs. + _skip_env = os.environ.get('JACKIFY_SKIP_WINE_COMPONENTS', '').strip() + if _skip_env: + _skip_set = {c.strip() for c in _skip_env.split(',') if c.strip()} + _removed = [c for c in components if c in _skip_set] + if _removed: + components = [c for c in components if c not in _skip_set] + self.logger.warning("JACKIFY_SKIP_WINE_COMPONENTS active - skipping %s (testing override)", _removed) + # All modlists now use their own AppID for wine components target_appid = self.appid @@ -414,7 +378,8 @@ class ModlistConfigurationMixin: modlist_ini_path=modlist_ini_path_obj, modlist_dir_path=modlist_dir_path_obj, modlist_sdcard=self.modlist_sdcard, - steam_libraries=steam_libraries + steam_libraries=steam_libraries, + compat_data_path=getattr(self, 'compat_data_path', None) ): self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini. Configuration aborted.") self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini.") @@ -447,7 +412,7 @@ class ModlistConfigurationMixin: self.logger.debug("No existing download_directory value found in ModOrganizer.ini; skipping normalisation") # Step 8.5: Align /home vs /var/home basis for Z: paths to match modlist install directory. - # This is intentionally separate from broad binary-path rewriting so it still runs when + # Kept separate from broad binary-path rewriting so it still runs when # engine-installed workflows skip edit_binary_working_paths. if not self.path_handler.align_home_path_basis( modlist_ini_path=modlist_ini_path_obj, @@ -625,12 +590,21 @@ class ModlistConfigurationMixin: wine_bin = self._find_wine_binary_for_registry() if compatdata_path and wine_bin: from jackify.backend.services.tool_config_service import apply_tool_config + # NSF/CSF modlists need the global *mscoree=native that winetricks dotnet48 + # set: the per-exe scoping this function applies starves NSF's CLR hosting + # (verified directly). So preserve the global override for them. + # dotnet9 SDK install also flips the prefix to win11; NSF/CSF was only + # verified working on win10 + global-native, so we keep that state and skip + # the dotnet9/win11 step. Whether Synthesis runs under global-native on an NSF + # prefix is untested - revisit if a modlist needs live Synthesis. + _nsf = getattr(self, '_nsf_detected', False) apply_tool_config( compatdata_path, wine_bin, log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None, - install_dotnet9_sdk=True, + install_dotnet9_sdk=not _nsf, install_fxc2_d3dcompiler=True, + preserve_global_mscoree=_nsf, ) self.logger.info("Step 15: Tool compatibility settings applied") else: diff --git a/jackify/backend/handlers/modlist_fixup_handler.py b/jackify/backend/handlers/modlist_fixup_handler.py new file mode 100644 index 0000000..d0f92df --- /dev/null +++ b/jackify/backend/handlers/modlist_fixup_handler.py @@ -0,0 +1,183 @@ +"""Modlist fixup handler. + +Applies known binary fixes to installed modlists during the configure phase. +Each fix is idempotent: it checks the current state before acting and skips +if the fix is already in place. +""" +import hashlib +import logging +import os +import subprocess +import tempfile +import urllib.request +from pathlib import Path +from typing import Callable, Optional + +from jackify.shared.paths import get_jackify_data_dir + +logger = logging.getLogger(__name__) + +# JContainers SE - Linux crash fix +# Nexus releases up to and including 4.2.9.0 crash on Linux. +# Fixed build from https://github.com/rfortier/JContainers-rwf (pending Nexus release). +# We replace the DLL in-place in whichever mod directory ships it. +_JCONTAINERS_FIXED_SHA256 = "4c00d7194c61097361e8f93521d79752a975fca5f54446e391debb0c56999590" +_JCONTAINERS_FIXED_URL = ( + "https://github.com/rfortier/JContainers-rwf/releases/download/v4.2.13.2/" + "JContainers64-v4.2.13.2.for.1.6.1170.patch.luajit.with.gc64.7z" +) +_JCONTAINERS_DLL_NAME = "JContainers64.dll" +_JCONTAINERS_ARCHIVE_NAME = "JContainers64-v4.2.13.2.linux-fix.7z" + + +def _sha256(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def _get_7z() -> Optional[Path]: + candidates = [] + appdir = os.environ.get("APPDIR") + if appdir: + candidates.append(Path(appdir) / "opt" / "jackify" / "tools" / "7z") + candidates.append(Path(__file__).parent.parent.parent / "tools" / "7z") + for p in candidates: + if p.exists() and os.access(p, os.X_OK): + return p + return None + + +def _find_dll_in_archive(seven_z: Path, archive: Path) -> Optional[str]: + """Return the internal path of JContainers64.dll within the archive, or None.""" + try: + result = subprocess.run( + [str(seven_z), "l", "-ba", "-slt", str(archive)], + capture_output=True, text=True, timeout=30, + ) + for line in result.stdout.splitlines(): + if line.startswith("Path = ") and line.lower().endswith(_JCONTAINERS_DLL_NAME.lower()): + return line[7:].strip() + except Exception as exc: + logger.debug("7z list failed: %s", exc) + return None + + +_JCONTAINERS_TARGET_SKSE = "skse64_1_6_1170.dll" + + +def _detect_skse_dll(mods_dir: Path) -> Optional[str]: + """Return the SKSE loader DLL filename found in mods, or None.""" + for dll in mods_dir.glob("*/Root/skse64_*.dll"): + return dll.name + return None + + +def check_jcontainers_needs_fix(modlist_dir: Path, game_type: Optional[str]) -> list: + """Return list of JContainers64.dll paths that need replacing, or empty list.""" + if not game_type or "skyrim" not in game_type.lower(): + return [] + mods_dir = Path(modlist_dir) / "mods" + if not mods_dir.is_dir(): + return [] + if _detect_skse_dll(mods_dir) != _JCONTAINERS_TARGET_SKSE: + return [] + targets = list(mods_dir.glob(f"*/SKSE/Plugins/{_JCONTAINERS_DLL_NAME}")) + return [p for p in targets if _sha256(p) != _JCONTAINERS_FIXED_SHA256] + + +def apply_jcontainers_fix( + modlist_dir: Path, + game_var_full: Optional[str], + status_callback: Optional[Callable] = None, +) -> None: + """Replace a broken JContainers64.dll with the Linux-compatible build. + + Scoped to Skyrim-based modlists only. Skips silently if already fixed, + if no JContainers mod is found, or if required tools are unavailable. + """ + if not game_var_full or "skyrim" not in game_var_full.lower(): + return + + mods_dir = Path(modlist_dir) / "mods" + if not mods_dir.is_dir(): + return + + if _detect_skse_dll(mods_dir) != _JCONTAINERS_TARGET_SKSE: + return + + targets = list(mods_dir.glob(f"*/SKSE/Plugins/{_JCONTAINERS_DLL_NAME}")) + if not targets: + logger.debug("JContainers fix: no %s found under %s", _JCONTAINERS_DLL_NAME, mods_dir) + return + + needs_fix = [p for p in targets if _sha256(p) != _JCONTAINERS_FIXED_SHA256] + if not needs_fix: + logger.debug("JContainers fix: all instances already at known-good version") + return + + seven_z = _get_7z() + if not seven_z: + logger.warning("JContainers fix: 7z not available, cannot apply fix") + return + + if status_callback: + status_callback("Applying JContainers Linux compatibility fix") + logger.info("JContainers fix: %d instance(s) need replacement", len(needs_fix)) + + cache_dir = get_jackify_data_dir() / "component_cache" / "fixups" + cache_dir.mkdir(parents=True, exist_ok=True) + archive_path = cache_dir / _JCONTAINERS_ARCHIVE_NAME + + if not archive_path.exists(): + logger.info("JContainers fix: downloading fixed archive from %s", _JCONTAINERS_FIXED_URL) + try: + urllib.request.urlretrieve(_JCONTAINERS_FIXED_URL, archive_path) + except Exception as exc: + logger.error("JContainers fix: download failed: %s", exc) + return + + dll_internal_path = _find_dll_in_archive(seven_z, archive_path) + if not dll_internal_path: + logger.error("JContainers fix: could not locate %s inside archive", _JCONTAINERS_DLL_NAME) + archive_path.unlink(missing_ok=True) + return + + with tempfile.TemporaryDirectory() as tmp: + try: + subprocess.run( + [str(seven_z), "e", str(archive_path), f"-o{tmp}", dll_internal_path, "-y"], + capture_output=True, timeout=60, check=True, + ) + except subprocess.CalledProcessError as exc: + logger.error("JContainers fix: extraction failed: %s", exc) + archive_path.unlink(missing_ok=True) + return + + extracted = Path(tmp) / _JCONTAINERS_DLL_NAME + if not extracted.exists(): + logger.error("JContainers fix: %s not found after extraction", _JCONTAINERS_DLL_NAME) + archive_path.unlink(missing_ok=True) + return + + actual_hash = _sha256(extracted) + if actual_hash != _JCONTAINERS_FIXED_SHA256: + logger.error( + "JContainers fix: extracted DLL hash %s does not match expected %s, aborting", + actual_hash, _JCONTAINERS_FIXED_SHA256, + ) + archive_path.unlink(missing_ok=True) + return + + import shutil + for target in needs_fix: + try: + backup = target.with_suffix(".dll.bak") + shutil.copy2(str(target), str(backup)) + logger.info("JContainers fix: backed up %s -> %s", target.name, backup.name) + shutil.copy2(str(extracted), str(target)) + logger.info("JContainers fix: replaced %s", target) + except Exception as exc: + logger.error("JContainers fix: failed to replace %s: %s", target, exc) diff --git a/jackify/backend/handlers/modlist_install_cli_configuration.py b/jackify/backend/handlers/modlist_install_cli_configuration.py index cc58721..4ea7475 100644 --- a/jackify/backend/handlers/modlist_install_cli_configuration.py +++ b/jackify/backend/handlers/modlist_install_cli_configuration.py @@ -485,8 +485,11 @@ class ModlistInstallCLIConfigurationMixin: print(f"{COLOR_INFO}{message}{COLOR_RESET}") try: + from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir + _download_dir = resolve_mo2_download_dir(Path(install_dir_str)) _result = prefix_service.run_working_workflow( - shortcut_name, install_dir_str, mo2_exe_path, _cli_progress, steamdeck=self.steamdeck + shortcut_name, install_dir_str, mo2_exe_path, _cli_progress, + steamdeck=self.steamdeck, download_dir=_download_dir, ) except Exception as _wf_err: from jackify.shared.errors import JackifyError @@ -517,7 +520,6 @@ class ModlistInstallCLIConfigurationMixin: 'mo2_exe_path': mo2_exe_path, 'resolution': self.context.get('resolution'), 'skip_confirmation': is_gui_mode, - 'manual_steps_completed': True } from .menu_handler import ModlistMenuHandler diff --git a/jackify/backend/handlers/modlist_install_cli_nexus.py b/jackify/backend/handlers/modlist_install_cli_nexus.py index a73d63a..1c21b5c 100644 --- a/jackify/backend/handlers/modlist_install_cli_nexus.py +++ b/jackify/backend/handlers/modlist_install_cli_nexus.py @@ -111,34 +111,10 @@ class ModlistInstallCLINexusMixin: return [] def _enhance_nexus_error(self, line: str) -> str: - """ - Enhance Nexus download error messages by adding the mod URL for easier troubleshooting. - """ - import re - - # Pattern to match Nexus download errors with ModID and FileID - nexus_error_pattern = r"Failed to download '[^']+' from Nexus \(Game: ([^,]+), ModID: (\d+), FileID: \d+\):" - - match = re.search(nexus_error_pattern, line) - if match: - game_name = match.group(1) - mod_id = match.group(2) - - # Map game names to Nexus URL segments - game_url_map = { - 'SkyrimSpecialEdition': 'skyrimspecialedition', - 'Skyrim': 'skyrim', - 'Fallout4': 'fallout4', - 'FalloutNewVegas': 'newvegas', - 'Oblivion': 'oblivion', - 'Starfield': 'starfield' - } - - game_url = game_url_map.get(game_name, game_name.lower()) - mod_url = f"https://www.nexusmods.com/{game_url}/mods/{mod_id}" - - # Add URL on next line for easier debugging - return f"{line}\n Nexus URL: {mod_url}" - + """Enhance Nexus download error messages by adding the mod URL for easier troubleshooting.""" + from jackify.backend.utils.engine_error_parser import nexus_url_from_error_line + url = nexus_url_from_error_line(line) + if url: + return f"{line}\n Nexus URL: {url}" return line diff --git a/jackify/backend/handlers/modlist_install_cli_ttw.py b/jackify/backend/handlers/modlist_install_cli_ttw.py index cd22bc1..3d839bb 100644 --- a/jackify/backend/handlers/modlist_install_cli_ttw.py +++ b/jackify/backend/handlers/modlist_install_cli_ttw.py @@ -69,7 +69,7 @@ class ModlistInstallCLITTWMixin: print(f"\n{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}") print(f"{COLOR_INFO}TTW Integration Available{COLOR_RESET}") print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}") - print(f"\nThis modlist ({modlist_name}) supports Tale of Two Wastelands (TTW).") + print(f"\nThis modlist ({modlist_name}) requires Tale of Two Wastelands (TTW).") print(f"TTW combines Fallout 3 and New Vegas into a single game.") print(f"\nWould you like to install TTW now?") diff --git a/jackify/backend/handlers/modlist_wine_ops.py b/jackify/backend/handlers/modlist_wine_ops.py index 1567460..d5adbd4 100644 --- a/jackify/backend/handlers/modlist_wine_ops.py +++ b/jackify/backend/handlers/modlist_wine_ops.py @@ -1,11 +1,10 @@ """Wine/Proton operation methods for ModlistHandler (Mixin).""" from pathlib import Path -from typing import Tuple, Optional, List +from typing import Tuple, Optional import os import logging import subprocess import shutil -import time import vdf import json import configparser @@ -64,11 +63,11 @@ class ModlistWineOpsMixin: with open(str(config_vdf_path), 'r') as f: config_data = vdf.load(f, mapper=vdf.VDFDict) - # Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name + steam_config_section = config_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {}) compat_mapping = steam_config_section.get('CompatToolMapping', {}) app_mapping = compat_mapping.get(appid_to_check, {}) - proton_tool_name = app_mapping.get('name') # CORRECTED: Use lowercase 'name' - self.proton_ver = proton_tool_name # Store detected version + proton_tool_name = app_mapping.get('name') + self.proton_ver = proton_tool_name if proton_tool_name: self.logger.info(f"Proton tool name from config.vdf: {proton_tool_name}") @@ -156,19 +155,19 @@ class ModlistWineOpsMixin: self.logger.error(f"Steam userdata directory not found at {userdata_base}") return + images = [ + ("grid-hero.png", f"{appid}_hero.png"), + ("grid-logo.png", f"{appid}_logo.png"), + ("grid-tall.png", f"{appid}p.png"), + ("grid-wide.png", f"{appid}.png"), + ] + for user_dir in userdata_base.iterdir(): if not user_dir.is_dir() or user_dir.name == "0": continue grid_dir = user_dir / "config/grid" grid_dir.mkdir(parents=True, exist_ok=True) - images = [ - ("grid-hero.png", f"{appid}_hero.png"), - ("grid-logo.png", f"{appid}_logo.png"), - ("grid-tall.png", f"{appid}p.png"), - ("grid-wide.png", f"{appid}.png"), - ] - for src_name, dest_name in images: src_path = steam_icons_dir / src_name dest_path = grid_dir / dest_name @@ -193,19 +192,17 @@ class ModlistWineOpsMixin: self.logger.error(f"Failed to copy tenfoot image: {e}") elif wide_src.exists(): try: - from PySide6.QtGui import QImage - img = QImage(str(wide_src)) - if not img.isNull(): - scaled = img.scaled(600, 350) - scaled.save(str(tenfoot_dest)) - self.logger.info(f"Generated tenfoot image from landscape: {tenfoot_dest}") - else: - self.logger.warning(f"Could not load landscape image for tenfoot generation: {wide_src}") + shutil.copyfile(wide_src, tenfoot_dest) + self.logger.info(f"Copied landscape image as tenfoot fallback: {tenfoot_dest}") except Exception as e: - self.logger.warning(f"Could not generate tenfoot image: {e}") + self.logger.warning(f"Could not copy tenfoot image: {e}") - def _try_steamgriddb_artwork(self, appid: str, game_type: str = None, modlist_dir: str = None): - """Fetch default artwork from SteamGridDB when no modlist-provided SteamIcons exist.""" + missing_sources = [src for src, _ in images if not (steam_icons_dir / src).exists()] + if missing_sources: + self._try_steamgriddb_artwork(appid, game_type, modlist_dir, skip_existing=True) + + def _try_steamgriddb_artwork(self, appid: str, game_type: str = None, modlist_dir: str = None, skip_existing: bool = False): + """Fetch artwork from SteamGridDB. When skip_existing is True, slots already present in grid_dir are not overwritten.""" if not game_type and modlist_dir: from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist game_type = detect_game_type_from_modlist(modlist_dir) @@ -241,19 +238,23 @@ class ModlistWineOpsMixin: for src_name, dest_name in images: src = tmp_dir / src_name if src.exists(): + dest = grid_dir / dest_name + if skip_existing and dest.exists(): + continue try: - shutil.copyfile(src, grid_dir / dest_name) + shutil.copyfile(src, dest) except Exception as e: self.logger.warning(f"Failed to copy {src_name}: {e}") # Generate tenfoot from landscape + tenfoot_dest = grid_dir / f"{appid}_tenfoot.png" wide = tmp_dir / "grid-wide.png" - if wide.exists(): + if wide.exists() and not (skip_existing and tenfoot_dest.exists()): try: from PySide6.QtGui import QImage img = QImage(str(wide)) if not img.isNull(): - img.scaled(600, 350).save(str(grid_dir / f"{appid}_tenfoot.png")) + img.scaled(600, 350).save(str(tenfoot_dest)) except Exception as e: self.logger.debug(f"Could not generate tenfoot: {e}") @@ -271,7 +272,9 @@ class ModlistWineOpsMixin: # Determine game type game = (game_var_full or modlist_name or "").lower().replace(" ", "") # Add game-specific extras - if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game: + if "fallout4vr" in game or "fo4vr" in game: + extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "vcrun2012"] + elif "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game: extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"] elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game: extras += ["d3dx9_43", "d3dx9"] @@ -287,10 +290,13 @@ class ModlistWineOpsMixin: for key, components in self.MODLIST_WINE_COMPONENTS.items(): if key in modlist_lower: extras += components - # Remove duplicates while preserving order + # Remove duplicates while preserving order, then promote Wine EXE installers + # (dotnet48, dotnet40) to the front so the long-running installs happen first. seen = set() full_list = [x for x in default_components + extras if not (x in seen or seen.add(x))] - return full_list + _slow = [c for c in full_list if c in ('dotnet48', 'dotnet40')] + _rest = [c for c in full_list if c not in ('dotnet48', 'dotnet40')] + return _slow + _rest def _re_enforce_windows_10_mode(self): """ @@ -499,6 +505,9 @@ class ModlistWineOpsMixin: else: self.logger.error(f"Failed to set OnlyUseLatestCLR: returncode={result2.returncode}, stderr={result2.stderr}") + # NDP v4.8 keys (Release DWORD etc.) are written by the winetricks dotnet48 verb + # during component installation; they are not set here. + # Force wineserver to flush registry changes to disk if wineserver_binary: self.logger.debug("Flushing registry changes to disk via wineserver shutdown...") diff --git a/jackify/backend/handlers/native_component_installer.py b/jackify/backend/handlers/native_component_installer.py new file mode 100644 index 0000000..140cb41 --- /dev/null +++ b/jackify/backend/handlers/native_component_installer.py @@ -0,0 +1,503 @@ +"""Native Wine component installer. + +Direct-source replacements for winetricks components. +Falls back to winetricks -> protontricks for unsupported or failed components. +""" + +import datetime +import hashlib +import json +import logging +import os +import shutil +import subprocess +import tempfile +import time +import urllib.request +import zipfile +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from jackify.shared.paths import get_jackify_data_dir + +logger = logging.getLogger(__name__) + +_D3DCOMPILER_47_X86_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompiler_47_32.dll" +_D3DCOMPILER_47_X64_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompiler_47.dll" +_D3DCOMPILER_47_X86_SHA256 = "2ad0d4987fc4624566b190e747c9d95038443956ed816abfd1e2d389b5ec0851" +_D3DCOMPILER_47_X64_SHA256 = "4432bbd1a390874f3f0a503d45cc48d346abc3a8c0213c289f4b615bf0ee84f3" + +_DIRECTX_CAB_URL = "https://files.holarse-linuxgaming.de/mirrors/microsoft/directx_Jun2010_redist.exe" +_DIRECTX_CAB_SHA256 = "8746ee1a84a083a90e37899d71d50d5c7c015e69688a466aa80447f011780c0d" + +_VCRUN2022_X86_URL = "https://aka.ms/vs/17/release/vc_redist.x86.exe" +_VCRUN2022_X64_URL = "https://aka.ms/vs/17/release/vc_redist.x64.exe" + +_VCRUN2022_DLLS_X86 = ["concrt140.dll", "msvcp140.dll", "msvcp140_1.dll", "msvcp140_2.dll", + "msvcp140_atomic_wait.dll", "msvcp140_codecvt_ids.dll", + "vcamp140.dll", "vccorlib140.dll", "vcomp140.dll", "vcruntime140.dll"] +_VCRUN2022_DLLS_X64 = _VCRUN2022_DLLS_X86 + ["vcruntime140_1.dll"] + +_VCRUN2012_X86_URL = "https://download.microsoft.com/download/1/6/B/16B06F60-3B20-4FF2-B699-5E9B7962F9AE/VSU_4/vcredist_x86.exe" +_VCRUN2012_X64_URL = "https://download.microsoft.com/download/1/6/B/16B06F60-3B20-4FF2-B699-5E9B7962F9AE/VSU_4/vcredist_x64.exe" + + +# (x86_stage1_patterns, x64_stage1_patterns, x86_dll_filters, x64_dll_filters, regsvr32) +_DX_CFG: Dict[str, tuple] = { + "d3dcompiler_43": ( + ["*d3dcompiler_43*x86*"], ["*d3dcompiler_43*x64*"], + ["d3dcompiler_43.dll"], ["d3dcompiler_43.dll"], False), + "d3dx9": ( + ["*d3dx9*x86*"], ["*d3dx9*x64*"], + ["d3dx9_*.dll"], ["d3dx9_*.dll"], False), + "d3dx9_43": ( + ["*d3dx9*x86*"], ["*d3dx9*x64*"], + ["d3dx9_43.dll"], ["d3dx9_43.dll"], False), + "d3dx11_43": ( + ["*d3dx11_43*x86*"], ["*d3dx11_43*x64*"], + ["d3dx11_43.dll"], ["d3dx11_43.dll"], False), + "xact": ( + ["*_xact_*x86*", "*_x3daudio_*x86*", "*_xaudio_*x86*"], [], + ["xactengine*.dll", "xaudio*.dll", "x3daudio*.dll", "xapofx*.dll"], [], True), + "xact_x64": ( + [], ["*_xact_*x64*", "*_x3daudio_*x64*", "*_xaudio_*x64*"], + [], ["xactengine*.dll", "xaudio*.dll", "x3daudio*.dll", "xapofx*.dll"], True), +} + +_REGISTRY_WRITE_COMPONENTS = {"fontsmooth=rgb"} +_DLL_COPY_COMPONENTS = {"d3dcompiler_47"} +_DIRECTX_CAB_COMPONENTS = set(_DX_CFG.keys()) +_WINE_INSTALLER_COMPONENTS = {"vcrun2022", "vcrun2012", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "dotnet9", "dotnetdesktop9", "dotnet10", "dotnetdesktop10"} + +# dotnet48 is handled by the bundled winetricks verb (see winetricks_handler), not natively: +# the ndp48 in-place servicing corrupts mscorlib under Wine. +SUPPORTED_COMPONENTS = ( + _REGISTRY_WRITE_COMPONENTS + | _DLL_COPY_COMPONENTS + | _DIRECTX_CAB_COMPONENTS + | _WINE_INSTALLER_COMPONENTS +) + + +class NativeComponentInstaller: + """Direct-source Wine component installer. Handles Groups 1-4 from the native install spec.""" + + def __init__(self, wineprefix: str, wine_binary: str, wine_env: dict, log=None): + self.wineprefix = wineprefix + self.wine_binary = wine_binary + self.wine_env = wine_env + self.logger = log or logging.getLogger(__name__) + + def _emit_status(self, msg: str) -> None: + cb = getattr(self, '_status_callback', None) + if cb: + cb(msg) + + def _wine_env_base(self, **extra) -> dict: + base = {**self.wine_env, 'WINEPREFIX': self.wineprefix} + parts = [] + for src in (base, extra): + v = src.pop('WINEDLLOVERRIDES', None) + if v: + parts.append(v) + parts.append('winemenubuilder.exe=d') + base.update(extra) + base['WINEDLLOVERRIDES'] = ','.join(parts) + return base + + def install_components( + self, + components: List[str], + status_callback=None, + ) -> Tuple[List[str], List[str]]: + """Attempt native install. Returns (succeeded, remaining); remaining goes to winetricks.""" + self._status_callback = status_callback + succeeded = [] + remaining = [] + + native_candidates = [c for c in components if c in SUPPORTED_COMPONENTS] + + for component in components: + if component not in SUPPORTED_COMPONENTS: + remaining.append(component) + continue + + self._current_component = component + if status_callback: + status_callback(f"[NATIVE_INSTALL] {component}") + + try: + ok = self._install_component(component) + except Exception as exc: + self.logger.error("Native install of %s raised: %s", component, exc, exc_info=True) + ok = False + + if ok: + self.logger.info("Native install succeeded: %s", component) + succeeded.append(component) + self._write_winetricks_log(component) + else: + self.logger.warning("Native install failed for %s, falling through to winetricks", component) + remaining.append(component) + + if succeeded: + self._apply_dll_overrides() + + return succeeded, remaining + + def _install_component(self, component: str) -> bool: + if component in _REGISTRY_WRITE_COMPONENTS: + return self._install_registry_write(component) + if component in _DLL_COPY_COMPONENTS: + return self._install_dll_copy(component) + if component in _DIRECTX_CAB_COMPONENTS: + return self._install_directx_cab(component) + if component == "vcrun2022": + return self._install_vcrun2022() + if component == "vcrun2012": + return self._install_vcrun2012() + return self._install_dotnet_modern(component) + + def _write_winetricks_log(self, component: str) -> None: + log_path = Path(self.wineprefix) / 'winetricks.log' + try: + with open(log_path, 'a', encoding='utf-8') as f: + f.write(component + '\n') + except Exception as exc: + self.logger.warning("Could not write winetricks.log: %s", exc) + self._write_jackify_component_record(component) + + def _write_jackify_component_record(self, component: str) -> None: + record_path = Path(self.wineprefix) / 'jackify_components.json' + try: + record = json.loads(record_path.read_text(encoding='utf-8')) if record_path.is_file() else {} + except Exception: + record = {} + record[component] = {"method": "native", "timestamp": datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S')} + try: + record_path.write_text(json.dumps(record, indent=2), encoding='utf-8') + except Exception as exc: + self.logger.warning("Could not write jackify_components.json: %s", exc) + + def _apply_dll_overrides(self) -> None: + reg_file = Path(__file__).parent.parent / 'data' / 'dll_overrides.reg' + if not reg_file.is_file(): + self.logger.warning("dll_overrides.reg not found at %s", reg_file) + return + overrides: Dict[str, str] = {} + in_target = False + for line in reg_file.read_text(encoding='utf-8').splitlines(): + s = line.strip() + if s.startswith('['): + in_target = 'Wine\\DllOverrides' in s + elif in_target and s.startswith('"'): + try: + q = s.index('"', 1) + rest = s[q + 1:] + if rest.startswith('='): + overrides[s[1:q]] = rest[1:] + except (ValueError, IndexError): + pass + if overrides: + self._direct_reg_write(r'Software\Wine\DllOverrides', overrides) + self.logger.debug("DLL overrides written directly to user.reg (%d entries)", len(overrides)) + + def _download_file(self, url: str, dest: Path, sha256: str = "") -> bool: + if dest.is_file(): + if not sha256: + return True + if self._verify_sha256(dest, sha256): + return True + self.logger.warning("SHA256 mismatch on cached %s, re-downloading", dest.name) + dest.unlink() + component = getattr(self, '_current_component', dest.stem) + self.logger.info("Downloading %s ...", dest.name) + self._emit_status(f"Downloading {dest.name}...") + try: + req = urllib.request.Request(url, headers={'User-Agent': 'Jackify/1.0'}) + with urllib.request.urlopen(req, timeout=120) as resp: + total = int(resp.headers.get('Content-Length', 0) or 0) + downloaded = 0 + start = time.monotonic() + last_emit = start + chunk_size = 65536 + with open(dest, 'wb') as f: + while True: + chunk = resp.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + now = time.monotonic() + if total > 0 and now - last_emit >= 0.5: + pct = downloaded / total * 100.0 + elapsed = now - start + speed = downloaded / elapsed / 1048576.0 if elapsed > 0.05 else 0.0 + self._emit_status(f"[NATIVE_DL] {component} {pct:.1f} {speed:.1f}") + last_emit = now + return True + except Exception as exc: + self.logger.error("Download failed for %s: %s", url, exc) + if dest.is_file(): + dest.unlink() + return False + + def _verify_sha256(self, path: Path, expected: str) -> bool: + h = hashlib.sha256() + try: + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(65536), b''): + h.update(chunk) + return h.hexdigest().lower() == expected.lower() + except Exception: + return False + + def _get_cabextract(self) -> Optional[str]: + if os.environ.get('APPDIR'): + candidate = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'cabextract') + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + dev_candidate = str(Path(__file__).parent.parent.parent / 'tools' / 'cabextract') + if os.path.isfile(dev_candidate) and os.access(dev_candidate, os.X_OK): + return dev_candidate + return shutil.which('cabextract') + + def _install_registry_write(self, component: str) -> bool: + if component != "fontsmooth=rgb": + return False + return self._direct_reg_write( + r'Control Panel\Desktop', + { + 'FontSmoothing': '"2"', + 'FontSmoothingType': 'dword:00000002', + 'FontSmoothingGamma': 'dword:00000578', + }, + ) + + def _install_dll_copy(self, component: str) -> bool: + if component != "d3dcompiler_47": + return False + cache_dir = get_jackify_data_dir() / 'component_cache' / 'd3dcompiler' + cache_dir.mkdir(parents=True, exist_ok=True) + syswow64 = Path(self.wineprefix) / 'drive_c' / 'windows' / 'syswow64' + system32 = Path(self.wineprefix) / 'drive_c' / 'windows' / 'system32' + syswow64.mkdir(parents=True, exist_ok=True) + system32.mkdir(parents=True, exist_ok=True) + + for url, sha256, dest, fname in [ + (_D3DCOMPILER_47_X86_URL, _D3DCOMPILER_47_X86_SHA256, syswow64, 'd3dcompiler_47_32.dll'), + (_D3DCOMPILER_47_X64_URL, _D3DCOMPILER_47_X64_SHA256, system32, 'd3dcompiler_47.dll'), + ]: + cached = cache_dir / fname + if not self._download_file(url, cached, sha256): + return False + if not self._verify_sha256(cached, sha256): + self.logger.error("SHA256 mismatch on %s after download", fname) + cached.unlink() + return False + shutil.copy2(cached, dest / 'd3dcompiler_47.dll') + return True + + def _install_directx_cab(self, component: str) -> bool: + cfg = _DX_CFG.get(component) + if not cfg: + return False + cabextract = self._get_cabextract() + if not cabextract: + self.logger.warning("cabextract not available for %s", component) + return False + + cache_dir = get_jackify_data_dir() / 'component_cache' / 'directx' + cache_dir.mkdir(parents=True, exist_ok=True) + redist = cache_dir / 'directx_Jun2010_redist.exe' + if not self._download_file(_DIRECTX_CAB_URL, redist, _DIRECTX_CAB_SHA256): + return False + if not self._verify_sha256(redist, _DIRECTX_CAB_SHA256): + self.logger.error("SHA256 mismatch on DirectX redistributable") + redist.unlink() + return False + + x86_patterns, x64_patterns, x86_dll_filters, x64_dll_filters, needs_regsvr32 = cfg + syswow64, system32 = self._get_system_dirs() + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + for arch_tag, stage1_patterns, dll_filters, dest_dir in [ + ('x86', x86_patterns, x86_dll_filters, syswow64), + ('x64', x64_patterns, x64_dll_filters, system32), + ]: + if not stage1_patterns: + continue + arch_tmp = tmpdir_path / arch_tag + arch_tmp.mkdir() + + for pattern in stage1_patterns: + subprocess.run( + [cabextract, '-d', str(arch_tmp), '-L', '-F', pattern, str(redist)], + capture_output=True, + ) + + inner_cabs = list(arch_tmp.glob('*.cab')) + if not inner_cabs: + self.logger.error("No inner cabs found for %s %s", component, arch_tag) + return False + + for dll_filter in dll_filters: + for inner_cab in inner_cabs: + subprocess.run( + [cabextract, '-d', str(dest_dir), '-L', '-F', dll_filter, str(inner_cab)], + capture_output=True, + ) + + if needs_regsvr32: + com_dlls = list(syswow64.glob('xactengine*.dll')) + list(syswow64.glob('xaudio*.dll')) + com_dlls += list(system32.glob('xactengine*.dll')) + list(system32.glob('xaudio*.dll')) + self._register_xact_com(com_dlls) + return True + + def _install_vcrun2022(self) -> bool: + cabextract = self._get_cabextract() + if not cabextract: + self.logger.warning("cabextract not available for vcrun2022") + return False + + cache_dir = get_jackify_data_dir() / 'component_cache' / 'vcrun' + cache_dir.mkdir(parents=True, exist_ok=True) + x86 = cache_dir / 'vc_redist.x86.exe' + x64 = cache_dir / 'vc_redist.x64.exe' + if not self._download_file(_VCRUN2022_X86_URL, x86): + return False + if not self._download_file(_VCRUN2022_X64_URL, x64): + return False + + syswow64, system32 = self._get_system_dirs() + # x86 inner cab is 'a10', x64 inner cab is 'a12' + with tempfile.TemporaryDirectory() as tmpdir: + for exe, cab, dest, dlls in [(x86, 'a10', syswow64, _VCRUN2022_DLLS_X86), + (x64, 'a12', system32, _VCRUN2022_DLLS_X64)]: + arch_tmp = Path(tmpdir) / cab + arch_tmp.mkdir() + subprocess.run([cabextract, '-d', str(arch_tmp), '-F', cab, str(exe)], capture_output=True) + inner_cab = arch_tmp / cab + if not inner_cab.is_file(): + self.logger.error("vcrun2022: inner cab '%s' not found in %s", cab, exe.name) + return False + for dll_name in dlls: + subprocess.run([cabextract, '-d', str(dest), '-F', dll_name, str(inner_cab)], capture_output=True) + if not (dest / 'msvcp140.dll').is_file(): + self.logger.error("vcrun2022: msvcp140.dll not extracted to %s", dest) + return False + return True + + def _install_vcrun2012(self) -> bool: + cabextract = self._get_cabextract() + if not cabextract: + self.logger.warning("cabextract not available for vcrun2012") + return False + cache_dir = get_jackify_data_dir() / 'component_cache' / 'vcrun2012' + cache_dir.mkdir(parents=True, exist_ok=True) + x86 = cache_dir / 'vcredist_x86.exe' + x64 = cache_dir / 'vcredist_x64.exe' + if not self._download_file(_VCRUN2012_X86_URL, x86): + return False + if not self._download_file(_VCRUN2012_X64_URL, x64): + return False + syswow64, system32 = self._get_system_dirs() + with tempfile.TemporaryDirectory() as tmpdir: + for exe, dest in [(x86, syswow64), (x64, system32)]: + for cab_name in ('a2', 'a3'): + td = Path(tmpdir) / (exe.stem + cab_name) + td.mkdir() + subprocess.run([cabextract, '-d', str(td), '-F', cab_name, str(exe)], capture_output=True) + inner = td / cab_name + if not inner.is_file(): + continue + dd = td / 'x' + dd.mkdir() + subprocess.run([cabextract, '-d', str(dd), '-L', '-F', 'F_CENTRAL_*', str(inner)], capture_output=True) + for src in dd.iterdir(): + if src.name.startswith('f_central_'): + shutil.copy2(src, dest / (src.name[10:].rsplit('_', 1)[0] + '.dll')) + if not (syswow64 / 'msvcr110.dll').is_file(): + self.logger.error("vcrun2012: msvcr110.dll not extracted to syswow64") + return False + return True + + def _install_dotnet_modern(self, component: str) -> bool: + urls = self._get_dotnet_urls(component) + if not urls: + return False + x86_url, x64_url = urls + cache_dir = get_jackify_data_dir() / 'component_cache' / 'dotnet' + cache_dir.mkdir(parents=True, exist_ok=True) + x86_zip = cache_dir / Path(x86_url).name + x64_zip = cache_dir / Path(x64_url).name + if not self._download_file(x86_url, x86_zip): + return False + if not self._download_file(x64_url, x64_zip): + return False + pfx = Path(self.wineprefix) / 'drive_c' + for zip_path, dest in [(x86_zip, pfx / 'Program Files (x86)' / 'dotnet'), + (x64_zip, pfx / 'Program Files' / 'dotnet')]: + dest.mkdir(parents=True, exist_ok=True) + try: + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(dest) + except Exception as exc: + self.logger.error("%s zip extraction failed for %s: %s", component, zip_path.name, exc) + return False + return True + + def _get_dotnet_urls(self, component: str) -> Optional[Tuple[str, str]]: + manifest = Path(__file__).parent.parent / 'data' / 'native_components_versions.json' + try: + entry = json.loads(manifest.read_text()).get(component, {}) + x86 = entry.get('x86_zip_url', '') + x64 = entry.get('x64_zip_url', '') + if x86 and x64: + return x86, x64 + except Exception as exc: + self.logger.error("Could not load dotnet URLs for %s: %s", component, exc) + self.logger.error("No zip URLs for %s in versions manifest", component) + return None + + def _get_system_dirs(self) -> Tuple[Path, Path]: + s64 = Path(self.wineprefix) / 'drive_c' / 'windows' / 'syswow64' + s32 = Path(self.wineprefix) / 'drive_c' / 'windows' / 'system32' + s64.mkdir(parents=True, exist_ok=True) + s32.mkdir(parents=True, exist_ok=True) + return s64, s32 + + def _register_xact_com(self, dlls: List[Path]) -> None: + manifest = Path(__file__).parent.parent / 'data' / 'native_components_versions.json' + try: + clsid_map = json.loads(manifest.read_text()).get('xact_clsids', {}) + except Exception: + clsid_map = {} + for dll_path in dlls: + for clsid in clsid_map.get(dll_path.name.lower(), []): + dir_name = dll_path.parent.name.lower() + win_path = f'"C:\\\\windows\\\\{dir_name}\\\\{dll_path.name}"' + self._direct_reg_write( + f'Software\\Classes\\CLSID\\{clsid}\\InprocServer32', + {'@': win_path, 'ThreadingModel': '"Both"'}, + ) + + def _direct_reg_write(self, key_hkcu: str, values: Dict[str, str]) -> bool: + """Write values to user.reg without spawning Wine. Later sections take precedence, so append is correct.""" + user_reg = Path(self.wineprefix) / 'user.reg' + if not user_reg.is_file(): + self.logger.warning("user.reg not found at %s", user_reg) + return False + key_fmted = key_hkcu.replace('\\', '\\\\') + try: + with open(user_reg, 'a', encoding='utf-8') as f: + f.write(f'\n[{key_fmted}] {int(time.time())}\n') + for name, val in values.items(): + f.write(f'@={val}\n' if name == '@' else f'"{name}"={val}\n') + return True + except Exception as exc: + self.logger.error("Direct registry write failed for %s: %s", key_hkcu, exc) + return False + + diff --git a/jackify/backend/handlers/path_handler.py b/jackify/backend/handlers/path_handler.py index c16e767..4322879 100644 --- a/jackify/backend/handlers/path_handler.py +++ b/jackify/backend/handlers/path_handler.py @@ -10,7 +10,6 @@ from .path_handler_mo2 import ( PathHandlerMO2Mixin, TARGET_EXECUTABLES_LOWER, STOCK_GAME_FOLDERS, - SDCARD_PREFIX, ) from .path_handler_dxvk import PathHandlerDXVKMixin from .path_handler_steam import PathHandlerSteamMixin @@ -20,7 +19,6 @@ __all__ = [ 'PathHandler', 'TARGET_EXECUTABLES_LOWER', 'STOCK_GAME_FOLDERS', - 'SDCARD_PREFIX', ] diff --git a/jackify/backend/handlers/path_handler_game.py b/jackify/backend/handlers/path_handler_game.py index 84ec6ac..e152d13 100644 --- a/jackify/backend/handlers/path_handler_game.py +++ b/jackify/backend/handlers/path_handler_game.py @@ -131,31 +131,31 @@ class PathHandlerGameMixin: @classmethod def find_vanilla_game_paths(cls, game_names=None) -> Dict[str, Path]: - """For each known game, iterate all Steam libraries and look for the canonical game directory in steamapps/common.""" - GAME_DIR_NAMES = { - "Skyrim Special Edition": ["Skyrim Special Edition"], - "Fallout 4": ["Fallout 4"], - "Fallout New Vegas": ["Fallout New Vegas"], - "Oblivion": ["Oblivion"], - "Fallout 3": ["Fallout 3", "Fallout 3 goty"] + """Locate vanilla game installations via Steam, Heroic GOG, or Heroic Epic.""" + GAME_TYPE_MAP = { + "Skyrim Special Edition": "skyrim", + "Fallout 4": "fallout4", + "Fallout New Vegas": "falloutnv", + "Oblivion": "oblivion", + "Fallout 3": "fallout3", } if game_names is None: - game_names = list(GAME_DIR_NAMES.keys()) - all_steam_libraries = cls.get_all_steam_library_paths() - logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}") + game_names = list(GAME_TYPE_MAP.keys()) + + from jackify.backend.handlers.vanilla_game_finder import VanillaGameFinder + finder = VanillaGameFinder() found_games = {} for game in game_names: - possible_names = GAME_DIR_NAMES.get(game, [game]) - for lib in all_steam_libraries: - for name in possible_names: - candidate = lib / "steamapps" / "common" / name - logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}") - if candidate.is_dir(): - found_games[game] = candidate - logger.info(f"Found vanilla game directory for {game}: {candidate}") - break - if game in found_games: - break + game_type = GAME_TYPE_MAP.get(game) + if not game_type: + continue + result = finder.find(game_type) + if result: + path, store = result + found_games[game] = path + logger.info("Found vanilla game %s via %s at %s", game, store, path) + else: + logger.debug("No installation found for %s", game) return found_games def _detect_stock_game_path(self) -> bool: diff --git a/jackify/backend/handlers/path_handler_mo2.py b/jackify/backend/handlers/path_handler_mo2.py index 51588e0..02b926c 100644 --- a/jackify/backend/handlers/path_handler_mo2.py +++ b/jackify/backend/handlers/path_handler_mo2.py @@ -21,9 +21,10 @@ TARGET_EXECUTABLES_LOWER = [ "skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "sfse_loader.exe", "obse64_loader.exe", "falloutnv.exe" ] -STOCK_GAME_FOLDERS = ["Stock Game", "StockGame", "Game Root", "Stock Folder", "Skyrim Stock"] -SDCARD_PREFIX = '/run/media/mmcblk0p1/' - +STOCK_GAME_FOLDERS = [ + "Stock Game", "StockGame", "STOCK GAME", "Stock Game Folder", + "Game Root", "Stock Folder", "Skyrim Stock", "root/Skyrim Special Edition", +] class PathHandlerMO2Mixin: """Mixin providing ModOrganizer.ini path updates and formatting.""" @@ -324,7 +325,8 @@ class PathHandlerMO2Mixin: return False def edit_binary_working_paths(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool, - steam_libraries: Optional[List[Path]] = None) -> bool: + steam_libraries: Optional[List[Path]] = None, + compat_data_path: Optional[Path] = None) -> bool: """Update all binary paths and working directories in ModOrganizer.ini. Critical, regression-prone.""" try: logger.debug(f"Updating binary paths and working directories in {modlist_ini_path} to use root: {modlist_dir_path}") @@ -352,17 +354,32 @@ class PathHandlerMO2Mixin: logger.debug(f"Extracted existing gamePath: {existing_game_path}, drive letter: {gamepath_drive_letter}") break if modlist_sdcard and existing_game_path and existing_game_path.startswith('/run/media') and gamepath_line_index != -1: - sdcard_pattern = r'^/run/media/deck/[^/]+(/Games/.*)$' - match = re.match(sdcard_pattern, existing_game_path) - if match: - stripped_path = match.group(1) - windows_path = stripped_path.replace('/', '\\\\') + stripped_path = None + if compat_data_path: + dosdevices_d = compat_data_path / "pfx" / "dosdevices" / "d:" + if dosdevices_d.exists(): + try: + d_target = os.readlink(str(dosdevices_d)) + d_mount = Path(d_target).as_posix().rstrip('/') + if existing_game_path.startswith(d_mount): + stripped_path = existing_game_path[len(d_mount):] + logger.debug(f"Resolved SD card D: mount via dosdevices: {d_mount}") + except Exception as e: + logger.warning(f"Could not read dosdevices/d: symlink: {e}") + if stripped_path is None: + # Strip the /run/media/deck/ prefix to get the relative path + sdcard_pattern = r'^/run/media/[^/]+/[^/]+(.+)$' + m = re.match(sdcard_pattern, existing_game_path) + if m: + stripped_path = m.group(1) + else: + logger.warning(f"Could not strip SD card prefix from gamePath (no dosdevices symlink available): {existing_game_path}") + if stripped_path is not None: + windows_path = stripped_path.lstrip('/').replace('/', '\\\\') new_gamepath_value = f"D:\\\\{windows_path}" new_gamepath_line = f"gamePath = @ByteArray({new_gamepath_value})\n" logger.info(f"Updating gamePath for SD card: {lines[gamepath_line_index].strip()} -> {new_gamepath_line.strip()}") lines[gamepath_line_index] = new_gamepath_line - else: - logger.warning(f"SD card path doesn't match expected pattern: {existing_game_path}") game_path_updated = False binary_paths_updated = 0 working_dirs_updated = 0 @@ -601,6 +618,12 @@ class PathHandlerMO2Mixin: if not m: continue raw = m.group(1).strip() + ba = re.match(r'@ByteArray\((.+)\)$', raw) + if ba: + raw = ba.group(1).strip() + # Engine's RemapMO2File writes a raw Linux path directly + if raw.startswith('/'): + return raw # Expect Z:\\path\\... or D:\\path\\... (MO2 doubles backslashes in the file) drive_m = re.match(r'^([ZzDd]):(.+)$', raw) if not drive_m: diff --git a/jackify/backend/handlers/path_handler_steam.py b/jackify/backend/handlers/path_handler_steam.py index f15bd4a..8eb71ed 100644 --- a/jackify/backend/handlers/path_handler_steam.py +++ b/jackify/backend/handlers/path_handler_steam.py @@ -166,11 +166,11 @@ class PathHandlerSteamMixin: if r not in seen and r != main_resolved: seen.add(r) result.append(r) - for extra in (install_dir, download_dir): - mp = self.get_mountpoint(extra) if extra else None - if mp and mp not in seen: - seen.add(mp) - result.append(mp) + if download_dir: + p = str(Path(str(download_dir)).resolve()) + if p not in seen: + seen.add(p) + result.append(p) return result @staticmethod diff --git a/jackify/backend/handlers/progress_parser.py b/jackify/backend/handlers/progress_parser.py index ce08273..e8378ca 100644 --- a/jackify/backend/handlers/progress_parser.py +++ b/jackify/backend/handlers/progress_parser.py @@ -179,6 +179,8 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres if file_prog: result.file_progress = file_prog result.has_progress = True + if '[FILE_PROGRESS]' in line: + result.message = "" # Check if file counter was attached (for extraction or install phases) if hasattr(file_prog, '_file_counter'): result.file_counter = file_prog._file_counter diff --git a/jackify/backend/handlers/progress_parser_clf3.py b/jackify/backend/handlers/progress_parser_clf3.py new file mode 100644 index 0000000..be27411 --- /dev/null +++ b/jackify/backend/handlers/progress_parser_clf3.py @@ -0,0 +1,277 @@ +""" +CLF3 Progress Parser + +Parses CLF3 --jackify stdout into InstallationProgress state. +Each line is a JSON object with a "type" field matching ProgressEvent variants. +""" +import json +import logging +import re + +from jackify.shared.progress_models import InstallationPhase, InstallationProgress, FileProgress, OperationType + +_COUNTER_RE = re.compile(r'\((\d+)/(\d+)\)\s*$') + +logger = logging.getLogger(__name__) + +_PHASE_MAP = { + "Downloading": InstallationPhase.DOWNLOAD, + "Validating": InstallationPhase.VALIDATE, + "Installing": InstallationPhase.INSTALL, + "Extracting": InstallationPhase.INSTALL, + "BSA Build": InstallationPhase.INSTALL, + "DDS Transform": InstallationPhase.INSTALL, + "Finalizing": InstallationPhase.FINALIZE, + "Cleanup": InstallationPhase.FINALIZE, +} + + +class CLF3ProgressStateManager: + """ + Parses CLF3 --progress-json stdout and maintains InstallationProgress state. + Implements the same process_line / get_state interface as ProgressStateManager. + """ + + def __init__(self): + self.state = InstallationProgress() + self.state.phase = InstallationPhase.INITIALIZATION + self.state.phase_name = "Starting" + self._total_archives: int = 0 + self._completed_archives: int = 0 + self._total_directives: int = 0 + self._completed_directives: int = 0 + # True once a real DownloadProgress event fires; distinguishes verify-only runs + self._seen_actual_download: bool = False + # name -> (downloaded, total, speed) + self._active_downloads: dict = {} + + def get_state(self) -> InstallationProgress: + return self.state + + def reset(self) -> None: + self.__init__() + + def process_line(self, line: str) -> bool: + stripped = line.strip() + if not stripped: + return False + if not stripped.startswith('{'): + idx = stripped.find('{') + if idx < 0: + return False + stripped = stripped[idx:] + try: + obj = json.loads(stripped) + except (json.JSONDecodeError, ValueError): + return False + + event_type = obj.get('type') + if not event_type: + return False + + handler = _HANDLERS.get(event_type) + if handler: + return handler(self, obj) + return False + + # -- event handlers -- + + def _on_download_progress(self, obj: dict) -> bool: + name = obj.get('name', '') + downloaded = obj.get('downloaded', 0) + total = obj.get('total', 0) + speed = obj.get('speed', 0.0) + self._active_downloads[name] = (downloaded, total, speed) + + self._seen_actual_download = True + + # Preserve "Downloading + Extracting" phase_name if concurrent mode is already active. + in_concurrent = ( + self._total_archives > 0 and + self._completed_archives > self._total_archives + ) + if self.state.phase != InstallationPhase.DOWNLOAD: + self.state.phase = InstallationPhase.DOWNLOAD + self.state.phase_name = "Downloading + Extracting" if in_concurrent else "Downloading" + elif self.state.phase_name not in ("Downloading", "Downloading + Extracting"): + self.state.phase_name = "Downloading" + + # Rebuild active_files from current download map + active_files = [] + for dl_name, (dl_downloaded, dl_total, dl_speed) in self._active_downloads.items(): + pct = (dl_downloaded / dl_total * 100.0) if dl_total > 0 else 0.0 + active_files.append(FileProgress( + filename=dl_name, + operation=OperationType.DOWNLOAD, + percent=pct, + current_size=dl_downloaded, + total_size=dl_total, + speed=dl_speed, + )) + self.state.active_files = active_files + + total_speed = sum(v[2] for v in self._active_downloads.values()) + speed_mb = total_speed / 1_048_576 + self.state.message = f"Downloading {len(active_files)} file(s) | {speed_mb:.1f} MB/s" + + if self._total_archives > 0: + if in_concurrent: + effective_max = self._total_archives * 2 + self.state.phase_step = self._completed_archives + self.state.phase_max_steps = effective_max + self.state.overall_percent = min(self._completed_archives / effective_max * 50.0, 50.0) + else: + self.state.phase_step = self._completed_archives + self.state.phase_max_steps = self._total_archives + self.state.overall_percent = min(self._completed_archives / self._total_archives * 50.0, 50.0) + return True + + def _on_download_complete(self, obj: dict) -> bool: + name = obj.get('name', '') + self._active_downloads.pop(name, None) + self.state.active_files = [f for f in self.state.active_files if f.filename != name] + return True + + def _on_archive_complete(self, obj: dict) -> bool: + index = obj.get('index', 0) + total = obj.get('total', 0) + if total: + self._total_archives = total + actual_total = total or self._total_archives + self._completed_archives = index + + # CLF3 emits a single cumulative ArchiveComplete counter spanning both download + # and extraction when running concurrently. index > actual_total means extraction + # events are being counted on top of the download events. + if actual_total > 0 and index > actual_total: + extracted = index - actual_total + effective_max = actual_total * 2 + self.state.phase_step = index + self.state.phase_max_steps = effective_max + self.state.overall_percent = min(index / effective_max * 50.0, 50.0) + self.state.phase_name = "Downloading + Extracting" + self.state.message = f"Downloaded: {actual_total}/{actual_total} | Extracting: {extracted}/{actual_total}" + elif not self._seen_actual_download and self.state.phase in (InstallationPhase.DOWNLOAD, InstallationPhase.VALIDATE): + self.state.phase_step = index + self.state.phase_max_steps = actual_total + if actual_total > 0: + self.state.overall_percent = min(index / actual_total * 50.0, 50.0) + self.state.phase_name = "Verifying Archives" + self.state.message = f"Verifying: {index}/{actual_total}" + else: + self.state.phase_step = index + self.state.phase_max_steps = actual_total + phase_name = self.state.phase_name or "" + phase_lower = phase_name.lower() + if "bsa" in phase_lower: + self.state.message = f"Building BSA: {index}/{actual_total}" + elif "dds" in phase_lower or "transform" in phase_lower: + self.state.message = f"Converting textures: {index}/{actual_total}" + elif "extract" in phase_lower: + if actual_total > 0: + self.state.overall_percent = min(index / actual_total * 50.0, 50.0) + self.state.message = f"Extracting: {index}/{actual_total}" + else: + if actual_total > 0: + self.state.overall_percent = min(index / actual_total * 50.0, 50.0) + self.state.message = f"Downloaded: {index}/{actual_total}" + return True + + def _on_download_skipped(self, obj: dict) -> bool: + count = obj.get('count', 0) + # ArchiveComplete tracks the authoritative cumulative index; don't double-count here + self.state.message = f"Skipped {count} already-downloaded archive(s)" + return True + + def _on_phase_change(self, obj: dict) -> bool: + phase_label = obj.get('phase', '') + phase = InstallationPhase.UNKNOWN + for key, val in _PHASE_MAP.items(): + if key.lower() in phase_label.lower(): + phase = val + break + self.state.phase = phase + # Default DOWNLOAD phase to "Verifying"; _on_download_progress flips it + # to "Downloading" the first time an actual download event arrives. + if phase == InstallationPhase.DOWNLOAD: + self.state.phase_name = "Verifying" + self.state.message = "Verifying" + else: + self.state.phase_name = phase_label + self.state.message = phase_label + self.state.phase_step = 0 + self.state.phase_max_steps = 0 + self.state.active_files = [] + self._active_downloads.clear() + self._seen_actual_download = False + logger.debug("CLF3 phase: %s -> %s", phase_label, phase) + return True + + def _on_directive_complete(self, obj: dict) -> bool: + index = obj.get('index', 0) + total = obj.get('total', 0) + if total: + self._total_directives = total + self._completed_directives = index + self.state.phase_step = index + self.state.phase_max_steps = total or self._total_directives + if total > 0: + self.state.overall_percent = 50.0 + min(index / total * 50.0, 50.0) + effective_total = total or self._total_directives + phase_lower = (self.state.phase_name or "").lower() + if "bsa" in phase_lower: + self.state.message = f"Building BSA: {index}/{effective_total}" + elif "dds" in phase_lower or "transform" in phase_lower: + self.state.message = f"Converting textures: {index}/{effective_total}" + else: + self.state.message = f"Installing: {index}/{effective_total} files" + return True + + def _on_directive_phase_started(self, obj: dict) -> bool: + directive_type = obj.get('directive_type', '') + total = obj.get('total', 0) + if total: + self._total_directives = total + self.state.message = f"Processing {directive_type} ({total} files)" + if self.state.phase not in (InstallationPhase.INSTALL, InstallationPhase.FINALIZE): + self.state.phase = InstallationPhase.INSTALL + self.state.phase_name = "Installing" + return True + + def _on_status(self, obj: dict) -> bool: + message = obj.get('message', '') + if not message: + return False + self.state.message = message + m = _COUNTER_RE.search(message) + if m: + step = int(m.group(1)) + total = int(m.group(2)) + self.state.phase_step = step + self.state.phase_max_steps = total + if total > 0: + # Streaming extraction emits "Extracting (N/total)" Status messages + # instead of phase_start+overall_inc. Detect these and drive 50-100% progress. + # Only transition to INSTALL when no downloads are active; during concurrent + # download+extract we stay in DOWNLOAD phase and track 0-50%. + if message.startswith("Extracting ") and not self._active_downloads: + self.state.phase = InstallationPhase.INSTALL + self.state.phase_name = "Extracting" + self.state.overall_percent = 50.0 + min(step / total * 50.0, 50.0) + elif self.state.phase == InstallationPhase.INSTALL: + self.state.overall_percent = 50.0 + min(step / total * 50.0, 50.0) + else: + self.state.overall_percent = min(step / total * 50.0, 50.0) + return True + + +_HANDLERS = { + 'DownloadProgress': CLF3ProgressStateManager._on_download_progress, + 'DownloadComplete': CLF3ProgressStateManager._on_download_complete, + 'ArchiveComplete': CLF3ProgressStateManager._on_archive_complete, + 'DownloadSkipped': CLF3ProgressStateManager._on_download_skipped, + 'PhaseChange': CLF3ProgressStateManager._on_phase_change, + 'DirectiveComplete': CLF3ProgressStateManager._on_directive_complete, + 'DirectivePhaseStarted': CLF3ProgressStateManager._on_directive_phase_started, + 'Status': CLF3ProgressStateManager._on_status, +} diff --git a/jackify/backend/handlers/progress_parser_example.py b/jackify/backend/handlers/progress_parser_example.py deleted file mode 100644 index 02b6e0b..0000000 --- a/jackify/backend/handlers/progress_parser_example.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Example usage of ProgressParser - -This file demonstrates how to use the progress parser to extract -structured information from jackify-engine output. - -R&D NOTE: This is experimental code for investigation purposes. -""" - -from jackify.backend.handlers.progress_parser import ProgressStateManager - - -def example_usage(): - """Example of how to use the progress parser.""" - - # Create state manager - state_manager = ProgressStateManager() - - # Simulate processing lines from jackify-engine output - sample_lines = [ - "[00:00:00] === Installing files ===", - "[00:00:05] [12/14] Installing files (1.1GB/56.3GB)", - "[00:00:10] Installing: Enderal Remastered Armory.7z (42%)", - "[00:00:15] Extracting: Mandragora Sprouts.7z (96%)", - "[00:00:20] Downloading at 45.2MB/s", - "[00:00:25] Extracting at 267.3MB/s", - "[00:00:30] Progress: 85%", - ] - - print("Processing sample output lines...\n") - - for line in sample_lines: - updated = state_manager.process_line(line) - if updated: - state = state_manager.get_state() - print(f"Line: {line}") - print(f" Phase: {state.phase.value} - {state.phase_name}") - print(f" Progress: {state.overall_percent:.1f}%") - print(f" Step: {state.phase_progress_text}") - print(f" Data: {state.data_progress_text}") - print(f" Active Files: {len(state.active_files)}") - for file_prog in state.active_files: - print(f" - {file_prog.filename}: {file_prog.percent:.1f}%") - print(f" Speeds: {state.speeds}") - print(f" Display: {state.display_text}") - print() - - -if __name__ == "__main__": - example_usage() - diff --git a/jackify/backend/handlers/shortcut_launch_options.py b/jackify/backend/handlers/shortcut_launch_options.py index f3b6415..3acdd2c 100644 --- a/jackify/backend/handlers/shortcut_launch_options.py +++ b/jackify/backend/handlers/shortcut_launch_options.py @@ -11,6 +11,128 @@ logger = logging.getLogger(__name__) class ShortcutLaunchOptionsMixin: """Mixin providing launch options and icon methods.""" + def get_shortcut_launch_options(self, app_name: str, exe_path: str) -> 'Optional[str]': + """Return current LaunchOptions for a shortcut, or None if the shortcut is not found.""" + shortcuts_file = self.path_handler._find_shortcuts_vdf() + if not shortcuts_file or not os.path.exists(shortcuts_file): + return None + try: + with open(shortcuts_file, 'rb') as f: + data = vdf.binary_loads(f.read()) + except Exception as e: + self.logger.debug(f"Could not read shortcuts.vdf: {e}") + return None + + def _norm(p: str) -> str: + try: + return os.path.normpath(os.path.abspath(p.strip().strip('"'))).lower() + except Exception: + return p.strip().strip('"').lower() + + exe_norm = _norm(exe_path) + for shortcut_data in data.get('shortcuts', {}).values(): + if (shortcut_data.get('AppName', '').strip() == app_name and + _norm(shortcut_data.get('Exe', '')) == exe_norm): + return shortcut_data.get('LaunchOptions', '') + return None + + def ensure_mounts_in_steam_compat(self, app_name: str, exe_path: str, *paths: str) -> str: + """Add mountpoints of any supplied paths to STEAM_COMPAT_MOUNTS if not already present. + + Reads existing launch options and appends only what is missing — never overwrites + unrelated options. Adds the top-level directory of each path so Proton's container + can bind-mount the subtree into the prefix. + + When Steam is running, the write is deferred: returns "steam_running" so the caller + can stop Steam first, call apply_pending_mounts_update(), then restart Steam. + + Returns: + "unchanged" — mounts already correct, no action needed + "updated" — Steam was not running; write succeeded + "steam_running" — changes needed but deferred; call apply_pending_mounts_update() + after stopping Steam + "failed" — shortcut not found or write error + """ + import re + from pathlib import Path as _Path + + def _is_covered(path: str, mounts: list) -> bool: + """Return True if path is already reachable via an existing mount entry. + + A path is covered if an existing mount entry is equal to it or is a + parent of it. Root '/' is excluded as a catch-all. + """ + p = _Path(path) + for mount in mounts: + if mount == '/': + continue + try: + p.relative_to(mount) + return True + except ValueError: + pass + return False + + current = self.get_shortcut_launch_options(app_name, exe_path) + if current is None: + self.logger.warning(f"Shortcut '{app_name}' not found in shortcuts.vdf; cannot update STEAM_COMPAT_MOUNTS") + return "failed" + + compat_re = re.compile(r'STEAM_COMPAT_MOUNTS="([^"]*)"') + m = compat_re.search(current) + existing = [p for p in m.group(1).split(':') if p] if m else [] + + mounts_to_add = [] + for p in paths: + if not p: + continue + if not _is_covered(p, existing) and p not in mounts_to_add: + mounts_to_add.append(p) + + if not mounts_to_add: + self.logger.debug(f"STEAM_COMPAT_MOUNTS for '{app_name}' already covers required paths") + return "unchanged" + + if m: + updated_val = ':'.join(existing + mounts_to_add) + updated = compat_re.sub(f'STEAM_COMPAT_MOUNTS="{updated_val}"', current) + else: + val = ':'.join(mounts_to_add) + prefix = f'STEAM_COMPAT_MOUNTS="{val}"' + updated = f'{prefix} {current}' if current.strip() else f'{prefix} %command%' + + self.logger.info(f"STEAM_COMPAT_MOUNTS update needed for '{app_name}': adding {mounts_to_add}") + + try: + from jackify.backend.services.steam_restart_service import get_steam_processes + steam_running = bool(get_steam_processes()) + except Exception: + steam_running = False + + if steam_running: + # Defer the write — Steam holds shortcuts.vdf in memory and would clobber it. + # Store the pending options so the GUI can stop Steam, apply, then restart. + self._pending_mounts_app_name = app_name + self._pending_mounts_exe_path = exe_path + self._pending_mounts_options = updated + return "steam_running" + + success = self.update_shortcut_launch_options(app_name, exe_path, updated) + return "updated" if success else "failed" + + def apply_pending_mounts_update(self) -> bool: + """Write a deferred STEAM_COMPAT_MOUNTS update. Call only after Steam has stopped.""" + app_name = getattr(self, '_pending_mounts_app_name', None) + exe_path = getattr(self, '_pending_mounts_exe_path', None) + options = getattr(self, '_pending_mounts_options', None) + if not (app_name and exe_path and options): + self.logger.warning("apply_pending_mounts_update called with no pending update") + return False + self._pending_mounts_app_name = None + self._pending_mounts_exe_path = None + self._pending_mounts_options = None + return self.update_shortcut_launch_options(app_name, exe_path, options) + def update_shortcut_launch_options(self, app_name, exe_path, new_launch_options): """ Updates the LaunchOptions for a specific existing shortcut in shortcuts.vdf by matching AppName and Exe. diff --git a/jackify/backend/handlers/subprocess_utils.py b/jackify/backend/handlers/subprocess_utils.py index 542f67e..47902de 100644 --- a/jackify/backend/handlers/subprocess_utils.py +++ b/jackify/backend/handlers/subprocess_utils.py @@ -180,7 +180,7 @@ class ProcessManager: """ Shared process manager for robust subprocess launching, tracking, and cancellation. """ - def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False): + def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False, use_pty=False): self.cmd = cmd # Default to cleaned environment if None to prevent AppImage variable inheritance if env is None: @@ -192,6 +192,8 @@ class ProcessManager: self.bufsize = bufsize self.separate_stderr = separate_stderr self.enable_stdin = enable_stdin + self.use_pty = use_pty + self._pty_master_fd = None self.proc = None self.process_group_pid = None self._stdin_lock = threading.Lock() @@ -200,17 +202,39 @@ class ProcessManager: def _start_process(self): stderr_arg = subprocess.PIPE if self.separate_stderr else subprocess.STDOUT stdin_arg = subprocess.PIPE if self.enable_stdin else None - self.proc = subprocess.Popen( - self.cmd, - stdin=stdin_arg, - stdout=subprocess.PIPE, - stderr=stderr_arg, - env=self.env, - cwd=self.cwd, - text=self.text, - bufsize=self.bufsize, - start_new_session=True - ) + if self.use_pty: + import pty + master_fd, slave_fd = pty.openpty() + self._pty_master_fd = master_fd + # TERM=dumb + NO_COLOR prevent CLF3 from using cursor movement or ANSI + # sequences (which would cause in-place overwrites we can't capture). + # isatty() still returns True on the slave, so Rust line-buffers stdout. + pty_env = dict(self.env) if self.env else {} + pty_env['TERM'] = 'dumb' + pty_env['NO_COLOR'] = '1' + self.proc = subprocess.Popen( + self.cmd, + stdin=stdin_arg, + stdout=slave_fd, + stderr=stderr_arg, + env=pty_env, + cwd=self.cwd, + start_new_session=True, + pass_fds=(slave_fd,), + ) + os.close(slave_fd) + else: + self.proc = subprocess.Popen( + self.cmd, + stdin=stdin_arg, + stdout=subprocess.PIPE, + stderr=stderr_arg, + env=self.env, + cwd=self.cwd, + text=self.text, + bufsize=self.bufsize, + start_new_session=True, + ) self.process_group_pid = os.getpgid(self.proc.pid) def cancel(self, timeout_terminate=2, timeout_kill=1, max_cleanup_attempts=3): @@ -267,6 +291,12 @@ class ProcessManager: cleanup_attempts += 1 finally: # Always close pipes - unblocks threads blocked on read(1) or iterating stderr + if self._pty_master_fd is not None: + try: + os.close(self._pty_master_fd) + except Exception: + pass + self._pty_master_fd = None if self.proc: for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr): if pipe: @@ -289,6 +319,11 @@ class ProcessManager: return None def read_stdout_char(self): + if self._pty_master_fd is not None: + try: + return os.read(self._pty_master_fd, 1) + except (OSError, IOError): + return None if self.proc and self.proc.stdout: try: return self.proc.stdout.read(1) diff --git a/jackify/backend/handlers/ttw_installer_backend.py b/jackify/backend/handlers/ttw_installer_backend.py index 924b6b9..4cbc49c 100644 --- a/jackify/backend/handlers/ttw_installer_backend.py +++ b/jackify/backend/handlers/ttw_installer_backend.py @@ -2,6 +2,8 @@ TTW installer backend: install_ttw_backend, start_ttw_installation, cleanup, stream output, integrate. """ +import os +import signal import subprocess from pathlib import Path from typing import Optional, Tuple @@ -144,7 +146,8 @@ class TTWInstallerBackendMixin: exe_dir = str(self.ttw_installer_executable_path.parent) process = subprocess.Popen( cmd, cwd=exe_dir, env=env, - stdout=output_fh, stderr=subprocess.STDOUT, bufsize=1 + stdout=output_fh, stderr=subprocess.STDOUT, bufsize=1, + start_new_session=True, ) self.logger.info("TTW_Linux_Installer process started (PID: %s), output to %s", process.pid, output_file) process._output_fh = output_fh @@ -155,7 +158,7 @@ class TTWInstallerBackendMixin: @staticmethod def cleanup_ttw_process(process): - """Clean up after TTW installation process.""" + """Terminate the TTW process group, then clean up file handles.""" if process: if hasattr(process, '_output_fh'): try: @@ -164,13 +167,18 @@ class TTWInstallerBackendMixin: pass if process.poll() is None: try: - process.terminate() + pgid = os.getpgid(process.pid) + os.killpg(pgid, signal.SIGTERM) process.wait(timeout=5) except Exception: try: - process.kill() + pgid = os.getpgid(process.pid) + os.killpg(pgid, signal.SIGKILL) except Exception: - pass + try: + process.kill() + except Exception: + pass from jackify.shared.paths import cleanup_stale_tmp cleanup_stale_tmp() diff --git a/jackify/backend/handlers/ttw_installer_handler.py b/jackify/backend/handlers/ttw_installer_handler.py index 50a7034..0a3c5ad 100644 --- a/jackify/backend/handlers/ttw_installer_handler.py +++ b/jackify/backend/handlers/ttw_installer_handler.py @@ -74,10 +74,9 @@ class TTWInstallerHandler(TTWInstallerBackendMixin): def _check_installation(self): """Check if TTW_Linux_Installer is installed at expected location. - + Checks for both old format (ttw_linux_gui) and new format (mpi_installer) executables. """ - self._ensure_dirs_exist() # Check for both old (ttw_linux_gui) and new (mpi_installer) executable names exe_names = [TTW_INSTALLER_EXECUTABLE_NAME, "mpi_installer"] @@ -104,7 +103,6 @@ class TTWInstallerHandler(TTWInstallerBackendMixin): (success: bool, message: str) """ try: - self._ensure_dirs_exist() target_dir = Path(install_dir) if install_dir else self.ttw_installer_dir target_dir.mkdir(parents=True, exist_ok=True) diff --git a/jackify/backend/handlers/vanilla_game_finder.py b/jackify/backend/handlers/vanilla_game_finder.py new file mode 100644 index 0000000..bf80318 --- /dev/null +++ b/jackify/backend/handlers/vanilla_game_finder.py @@ -0,0 +1,250 @@ +""" +VanillaGameFinder + +Locates vanilla game installations across Steam and Heroic (GOG/Epic) +without requiring manual path entry from the user. + +Detection order: Steam appmanifest -> Heroic GOG -> Heroic Epic. +No manual path override is offered here; that belongs in user settings. +""" +import json +import logging +import re +from pathlib import Path +from typing import Dict, List, NamedTuple, Optional, Tuple + +logger = logging.getLogger(__name__) + +# Store identifiers returned alongside the detected path. +STORE_STEAM = "steam" +STORE_GOG = "gog" +STORE_EPIC = "epic" +STORE_UNKNOWN = "unknown" + +GameLocation = Tuple[Path, str] # (path, store) + + +class SteamEntry(NamedTuple): + app_id: str + dir_names: List[str] + + +# Maps Jackify game_type -> ordered list of Steam candidates to try. +# Multiple entries handle games with ambiguous type strings (e.g. skyrim = SSE or LE). +_STEAM_CATALOG: Dict[str, List[SteamEntry]] = { + 'skyrim': [ + SteamEntry('489830', ['Skyrim Special Edition']), + SteamEntry('72850', ['Skyrim']), + ], + 'skyrimvr': [SteamEntry('611670', ['Skyrim VR'])], + 'fallout4': [SteamEntry('377160', ['Fallout 4'])], + 'fallout4vr': [SteamEntry('611660', ['Fallout 4 VR'])], + 'falloutnv': [SteamEntry('22380', ['Fallout New Vegas', 'FalloutNV'])], + 'fallout3': [ + SteamEntry('22300', ['Fallout 3', 'Fallout3']), + SteamEntry('22370', ['Fallout 3 goty', 'Fallout 3 GOTY', 'Fallout3']), + ], + 'oblivion': [SteamEntry('22330', ['Oblivion'])], + 'oblivion_remastered': [SteamEntry('2623190', ['Oblivion Remastered'])], + 'morrowind': [SteamEntry('22320', ['Morrowind'])], + 'starfield': [SteamEntry('1716740', ['Starfield'])], + 'enderal': [ + SteamEntry('976620', ['Enderal Special Edition']), + SteamEntry('933480', ['Enderal Forgotten Stories', 'Enderal']), + ], + 'bg3': [SteamEntry('1086940', ['Baldurs Gate 3', "Baldur's Gate 3"])], + 'cp2077': [SteamEntry('1091500', ['Cyberpunk 2077'])], + 'witcher3': [SteamEntry('292030', ['The Witcher 3 Wild Hunt', 'The Witcher 3: Wild Hunt'])], + 'darksouls3': [SteamEntry('374320', ['DARK SOULS III'])], + 'eldenring': [SteamEntry('1245620', ['ELDEN RING'])], + 'sekiro': [SteamEntry('814380', ['Sekiro'])], + 'mountandblade2': [SteamEntry('261550', ['Mount & Blade II Bannerlord'])], + 'stardewvalley': [SteamEntry('413150', ['Stardew Valley'])], + 'dragonageinquisition': [SteamEntry('1222690', ['Dragon Age Inquisition'])], + 'hogwartslegacy': [SteamEntry('990080', ['Hogwarts Legacy'])], +} + +# Maps Jackify game_type -> list of GOG app IDs (appName in installed.json). +# Source: Fluorine-Manager/libs/basic_games/gog_utils.py approach + CLF3 known_games.rs IDs. +_HEROIC_GOG_CATALOG: Dict[str, List[str]] = { + 'falloutnv': ['1454587428'], + 'fallout3': ['1454315831'], + 'oblivion': ['1458058109'], + 'morrowind': ['1440163901'], + 'bg3': ['1456460669'], + 'cp2077': ['1423049311'], + 'witcher3': ['1495134320'], + 'skyrim': ['1711230643'], + 'stardewvalley': ['1453375253'], +} + +# Maps Jackify game_type -> list of Epic/Legendary app_name slugs (installed.json key). +_HEROIC_EPIC_CATALOG: Dict[str, List[str]] = { + 'fallout3': ['adeae8bbfc94427db57c7dfecce3f1d4'], + 'falloutnv': ['5daeb974a22a435988892319b3a4f476'], +} + +# Epic installs some games into a language-specific subdirectory inside the install root. +# Maps game_type -> glob pattern to find the real game directory one level down. +# Language suffix varies (English, German, French, ...) so we glob rather than hardcode. +_EPIC_SUBDIR_GLOB: Dict[str, str] = { + 'fallout3': 'Fallout 3 GOTY *', + 'falloutnv': 'Fallout New Vegas *', +} + +# Candidate Heroic config roots: native install then Flatpak. +_HEROIC_CONFIG_ROOTS: List[Path] = [ + Path.home() / '.config' / 'heroic', + Path.home() / '.var' / 'app' / 'com.heroicgameslauncher.hgl' / 'config' / 'heroic', +] + + +class VanillaGameFinder: + """ + Locates vanilla (store-installed) game directories for a given Jackify game_type. + Returns a (Path, store) tuple so callers can warn when the game is not on Steam. + Searches Steam first, then Heroic-managed stores (GOG, Epic). + """ + + def find(self, game_type: str) -> Optional[GameLocation]: + """ + Return (path, store) for the detected game installation, or None. + store is one of: 'steam', 'gog', 'epic', 'unknown'. + """ + result = self._find_steam(game_type) + if result: + logger.info("VanillaGameFinder: found %s via Steam at %s", game_type, result) + return result, STORE_STEAM + + result = self._find_heroic(game_type) + if result: + path, store = result + logger.info("VanillaGameFinder: found %s via %s at %s", game_type, store, path) + return path, store + + logger.debug("VanillaGameFinder: no installation found for %s", game_type) + return None + + # ------------------------------------------------------------------ + # Steam + # ------------------------------------------------------------------ + + def _find_steam(self, game_type: str) -> Optional[Path]: + entries = _STEAM_CATALOG.get(game_type) + if not entries: + return None + try: + from jackify.backend.handlers.path_handler_steam import PathHandlerSteamMixin + library_paths = PathHandlerSteamMixin.get_all_steam_library_paths() + except Exception as e: + logger.debug("Steam library path detection failed: %s", e) + return None + + for library in library_paths: + steamapps = library / 'steamapps' + if not steamapps.is_dir(): + continue + for entry in entries: + path = self._check_steam_entry(steamapps, entry) + if path: + return path + return None + + def _check_steam_entry(self, steamapps: Path, entry: SteamEntry) -> Optional[Path]: + manifest = steamapps / f'appmanifest_{entry.app_id}.acf' + if not manifest.is_file(): + return None + try: + content = manifest.read_text(encoding='utf-8', errors='replace') + state_match = re.search(r'"StateFlags"\s+"(\d+)"', content) + if state_match and not (int(state_match.group(1)) & 4): + logger.debug("Skipping %s: StateFlags=%s (not fully installed)", manifest.name, state_match.group(1)) + return None + match = re.search(r'"installdir"\s+"([^"]+)"', content) + if match: + path = steamapps / 'common' / match.group(1) + if path.is_dir(): + return path + for name in entry.dir_names: + fallback = steamapps / 'common' / name + if fallback.is_dir(): + return fallback + except OSError as e: + logger.debug("Could not read appmanifest %s: %s", manifest, e) + return None + + # ------------------------------------------------------------------ + # Heroic (GOG via gog_store/installed.json, Epic via legendaryConfig) + # Approach adapted from Fluorine-Manager/libs/basic_games/gog_utils.py + # ------------------------------------------------------------------ + + def _find_heroic(self, game_type: str) -> Optional[Tuple[Path, str]]: + gog_ids = _HEROIC_GOG_CATALOG.get(game_type) + if gog_ids: + path = self._find_heroic_gog(gog_ids) + if path: + return path, STORE_GOG + + epic_ids = _HEROIC_EPIC_CATALOG.get(game_type) + if epic_ids: + path = self._find_heroic_epic(epic_ids, game_type=game_type) + if path: + return path, STORE_EPIC + + return None + + def _find_heroic_gog(self, app_ids: List[str]) -> Optional[Path]: + id_set = set(app_ids) + for config_root in _HEROIC_CONFIG_ROOTS: + installed_file = config_root / 'gog_store' / 'installed.json' + if not installed_file.is_file(): + continue + try: + data = json.loads(installed_file.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as e: + logger.debug("Could not read Heroic GOG installed.json %s: %s", installed_file, e) + continue + for entry in data.get('installed', []): + if not isinstance(entry, dict): + continue + if entry.get('appName') not in id_set: + continue + install_path = entry.get('install_path') or entry.get('installPath', '') + if install_path: + path = Path(install_path) + if path.is_dir(): + return path + return None + + def _find_heroic_epic(self, app_ids: List[str], game_type: str = '') -> Optional[Path]: + id_set = set(app_ids) + subdir_glob = _EPIC_SUBDIR_GLOB.get(game_type, '') + for config_root in _HEROIC_CONFIG_ROOTS: + installed_file = config_root / 'legendaryConfig' / 'legendary' / 'installed.json' + if not installed_file.is_file(): + continue + try: + data = json.loads(installed_file.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as e: + logger.debug("Could not read Heroic Epic installed.json %s: %s", installed_file, e) + continue + for app_name, entry in data.items(): + if not isinstance(entry, dict): + continue + if app_name not in id_set: + continue + install_path = entry.get('install_path', '') + if not install_path: + continue + path = Path(install_path) + if not path.is_dir(): + continue + # Epic installs some titles into a language-specific subdirectory. + # Glob for it rather than hardcoding the language suffix. + if subdir_glob: + matches = sorted(path.glob(subdir_glob)) + if matches: + logger.debug("Epic subdir match for %s: %s", game_type, matches[0]) + return matches[0] + return path + return None diff --git a/jackify/backend/handlers/wine_utils_proton.py b/jackify/backend/handlers/wine_utils_proton.py index 5a6fe19..1ced6a2 100644 --- a/jackify/backend/handlers/wine_utils_proton.py +++ b/jackify/backend/handlers/wine_utils_proton.py @@ -382,44 +382,56 @@ class WineUtilsProtonMixin: @staticmethod def scan_valve_proton_versions() -> List[Dict[str, Any]]: - """Scan for available Valve Proton versions with fallback priority.""" + """Scan for available Valve Proton versions. Discovers all installed X.Y releases dynamically.""" logger.info("Scanning for available Valve Proton versions...") found_versions = [] + seen_names: set = set() steam_libs = WineUtilsProtonMixin.get_steam_library_paths() if not steam_libs: logger.warning("No Steam library paths found") return [] - preferred_versions = [ - ("Proton - Experimental", 150), - ("Proton 10.0", 140), - ("Proton 9.0", 130), - ("Proton 9.0 (Beta)", 125) - ] + for steam_path in steam_libs: - logger.debug(f"Scanning Steam library: {steam_path}") - for version_name, priority in preferred_versions: - proton_path = steam_path / version_name - wine_bin = proton_path / "files" / "bin" / "wine" - if wine_bin.exists() and wine_bin.is_file(): - compat_name = WineUtilsProtonMixin.resolve_steam_compat_name(proton_path) - found_versions.append({ - 'name': version_name, - 'path': proton_path, - 'wine_bin': wine_bin, - 'priority': priority, - 'type': 'Valve-Proton', - 'steam_compat_name': compat_name, - }) - logger.debug(f"Found {version_name} at {proton_path}") - found_versions.sort(key=lambda x: x['priority'], reverse=True) - unique_versions = [] - seen_names = set() - for version in found_versions: - if version['name'] not in seen_names: - unique_versions.append(version) - seen_names.add(version['name']) - logger.info(f"Found {len(unique_versions)} unique Valve Proton version(s)") - return unique_versions + if not steam_path.is_dir(): + continue + for entry in steam_path.iterdir(): + if not entry.is_dir(): + continue + name = entry.name + wine_bin = entry / "files" / "bin" / "wine" + if not wine_bin.is_file(): + continue + if name in seen_names: + continue + + if name == "Proton - Experimental": + major, minor, is_beta = 9999, 9999, False + else: + m = re.match(r'^Proton (\d+)\.(\d+)(\s+\(Beta\))?$', name) + if not m: + continue + major, minor, is_beta = int(m.group(1)), int(m.group(2)), bool(m.group(3)) + + compat_name = WineUtilsProtonMixin.resolve_steam_compat_name(entry) + found_versions.append({ + 'name': name, + 'path': entry, + 'wine_bin': wine_bin, + 'priority': major * 10 + (0 if is_beta else 1), + 'major_version': major, + 'minor_version': minor, + 'type': 'Valve-Proton', + 'steam_compat_name': compat_name, + }) + seen_names.add(name) + logger.debug(f"Found Valve Proton: {name}") + + found_versions.sort( + key=lambda x: (x['major_version'], x['minor_version'], 0 if x['name'].endswith('(Beta)') else 1), + reverse=True, + ) + logger.info(f"Found {len(found_versions)} Valve Proton version(s)") + return found_versions @staticmethod def scan_all_proton_versions() -> List[Dict[str, Any]]: diff --git a/jackify/backend/handlers/winetricks_handler.py b/jackify/backend/handlers/winetricks_handler.py index e5f1f3a..cc789c9 100644 --- a/jackify/backend/handlers/winetricks_handler.py +++ b/jackify/backend/handlers/winetricks_handler.py @@ -7,6 +7,7 @@ Discovery, installation strategy, and verification live in mixins. """ import os +import shutil import subprocess import logging from pathlib import Path @@ -54,6 +55,45 @@ class WinetricksHandler( if not components_to_install: return True + native_wineprefix = env.get('WINEPREFIX', wineprefix) + wine_binary = env.get('WINE', '') + + # Native installer tier: direct-source downloads, no winetricks dependency. + native = None + if wine_binary: + try: + from .native_component_installer import NativeComponentInstaller + native = NativeComponentInstaller(native_wineprefix, wine_binary, env, self.logger) + except Exception as native_exc: + self.logger.warning("Native installer init failed: %s", native_exc) + + if native: + try: + _native_ok, components_to_install = native.install_components( + components_to_install, status_callback + ) + if not components_to_install: + self._set_windows_10_mode_after_install(native_wineprefix, env) + return True + except Exception as native_exc: + self.logger.warning("Native installer skipped due to error: %s", native_exc) + + # dotnet48 (NSF/CSF modlists) installs via the bundled winetricks verb. Fully remove + # Wine Mono first (files + mscoree stub + uninstaller entry + its NDP registry keys) so + # the prefix is in the clean "no .NET, no mono" state a fresh winetricks dotnet48 expects: + # this avoids the "same or higher version already installed" bail (the NDP keys are + # mono-registered, winetricks #2367) and lets winetricks complete the .NET first-run + # during the verb, so it does not fire at game launch. + # The status message persists through the install (the main winetricks call emits none + # before it blocks), so the UI shows dotnet48 rather than the prior native component. + if 'dotnet48' in components_to_install: + if status_callback: + status_callback("[NATIVE_INSTALL] dotnet48") + status_callback("Installing .NET Framework 4.8 (dotnet48) - the long step, can take several minutes") + self.logger.info("Installing dotnet48 via winetricks (NSF/CSF) - long-running step") + self._kill_wineserver_for_prefix(env) + self._remove_wine_mono(native_wineprefix) + # Flatpak Steam: use protontricks only; bundled winetricks is unreliable (e.g. from AppImage) flatpak_steam = False try: @@ -455,6 +495,70 @@ class WinetricksHandler( except Exception as e: self.logger.debug("Wineserver -k failed (non-fatal): %s", e) + def _remove_wine_mono(self, wineprefix: str) -> None: + """Fully remove Wine Mono so a clean winetricks dotnet48 install behaves as on a fresh + prefix - the state that installs without the "already installed" bail and without + deferring the .NET first-run to game launch. + + Removes: the mono mscoree.dll stubs, the mono runtime directory, the "Wine Mono" + uninstaller registry sections, and the mono-registered NET Framework Setup\\NDP keys + (those are what trigger the #2367 bail). Wineserver must be dead so the system.reg + edit persists. + """ + import time + time.sleep(2) + + for sub in ('system32', 'syswow64'): + dll = os.path.join(wineprefix, 'drive_c', 'windows', sub, 'mscoree.dll') + try: + if os.path.isfile(dll): + with open(dll, 'rb') as f: + is_mono_stub = b'WINE_MONO_OVERRIDES' in f.read() + if is_mono_stub: + os.unlink(dll) + self.logger.debug("Removed Mono mscoree.dll stub: %s", dll) + except Exception: + pass + + mono_dir = os.path.join(wineprefix, 'drive_c', 'windows', 'mono') + if os.path.isdir(mono_dir): + shutil.rmtree(mono_dir, ignore_errors=True) + self.logger.debug("Removed Wine Mono runtime directory") + + system_reg = os.path.join(wineprefix, 'system.reg') + if not os.path.isfile(system_reg): + return + try: + with open(system_reg, encoding='utf-8', errors='replace') as f: + lines = f.readlines() + out, section, drop = [], [], False + removed_mono, removed_ndp = 0, 0 + for line in lines: + if line.lstrip().startswith('['): + if section and not drop: + out.extend(section) + end = line.strip().rfind(']') + key = line.strip()[1:end].lower() if end > 0 else '' + drop = 'net framework setup\\\\ndp' in key + if drop: + removed_ndp += 1 + section = [line] + elif section: + section.append(line) + s = line.strip() + if s.startswith('"DisplayName"=') and 'Wine Mono' in s and not drop: + drop = True + removed_mono += 1 + else: + out.append(line) + if section and not drop: + out.extend(section) + with open(system_reg, 'w', encoding='utf-8') as f: + f.writelines(out) + self.logger.info("Removed Wine Mono: %d uninstaller section(s) + %d NDP section(s)", removed_mono, removed_ndp) + except Exception as exc: + self.logger.warning("Wine Mono registry removal failed (non-fatal): %s", exc) + def _cleanup_wine_processes(self): """Clean up winetricks processes only during component installation.""" try: diff --git a/jackify/backend/services/automated_prefix_game_utils.py b/jackify/backend/services/automated_prefix_game_utils.py index 53c7abe..4902690 100644 --- a/jackify/backend/services/automated_prefix_game_utils.py +++ b/jackify/backend/services/automated_prefix_game_utils.py @@ -19,69 +19,6 @@ logger = logging.getLogger(__name__) class GameUtilsMixin: """Mixin for game-related utility operations""" - # TODO post-0.6: remove this method - dead code, never called. - # Superseded by registry injection (game paths written directly into the modlist prefix). - # def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]: - # """ - # Generate launch options for FNV/Enderal games that require vanilla compatdata. - # - # Args: - # special_game_type: "fnv" or "enderal" - # modlist_install_dir: Directory where the modlist is installed - # - # Returns: - # Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed - # """ - # if not special_game_type or special_game_type not in ["fnv", "enderal"]: - # return None - # - # logger.info(f"Generating {special_game_type.upper()} launch options") - # - # # Map game types to AppIDs - # appid_map = {"fnv": "22380", "enderal": "976620"} - # appid = appid_map[special_game_type] - # - # # Find vanilla game compatdata - # from ..handlers.path_handler import PathHandler - # compatdata_path = PathHandler.find_compat_data(appid) - # if not compatdata_path: - # logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})") - # return None - # - # # Create STEAM_COMPAT_DATA_PATH string - # compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"' - # - # # Generate STEAM_COMPAT_MOUNTS if multiple libraries exist - # compat_mounts_str = "" - # try: - # all_libs = PathHandler.get_all_steam_library_paths() - # main_steam_lib_path_obj = PathHandler.find_steam_library() - # if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common": - # main_steam_lib_path = main_steam_lib_path_obj.parent.parent - # else: - # main_steam_lib_path = main_steam_lib_path_obj - # - # mount_paths = [] - # if main_steam_lib_path: - # main_resolved = main_steam_lib_path.resolve() - # for lib_path in all_libs: - # if lib_path.resolve() != main_resolved: - # mount_paths.append(str(lib_path.resolve())) - # - # if mount_paths: - # mount_paths_str = ':'.join(mount_paths) - # compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"' - # logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}") - # except Exception as e: - # logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}") - # - # # Combine all launch options - # launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip() - # launch_options = ' '.join(launch_options.split()) # Clean up spacing - # - # logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}") - # return launch_options - def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]: """Find a Steam game installation path by AppID and common names""" import os diff --git a/jackify/backend/services/automated_prefix_proton.py b/jackify/backend/services/automated_prefix_proton.py index 384dd06..4cd12e2 100644 --- a/jackify/backend/services/automated_prefix_proton.py +++ b/jackify/backend/services/automated_prefix_proton.py @@ -110,21 +110,14 @@ class ProtonOperationsMixin: with open(config_path, 'r') as f: config_data = vdf.load(f) - # Navigate to the correct location in the VDF structure - if 'Software' not in config_data: - config_data['Software'] = {} - if 'Valve' not in config_data['Software']: - config_data['Software']['Valve'] = {} - if 'Steam' not in config_data['Software']['Valve']: - config_data['Software']['Valve']['Steam'] = {} - - # Get or create CompatToolMapping - if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']: - config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {} + # config.vdf root key is "InstallConfigStore" + ics = config_data.setdefault('InstallConfigStore', {}) + sw = ics.setdefault('Software', {}) + valve = sw.setdefault('Valve', {}) + steam = valve.setdefault('Steam', {}) + ctm = steam.setdefault('CompatToolMapping', {}) - # Set the Proton version for this AppID using Steam's expected format - # Steam requires a dict with 'name', 'config', and 'priority' keys - config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(appid)] = { + ctm[str(appid)] = { 'name': proton_version, 'config': '', 'priority': '250' @@ -148,7 +141,7 @@ class ProtonOperationsMixin: # Verify it was set correctly with open(config_path, 'r') as f: verify_data = vdf.load(f) - compat_mapping = verify_data.get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}).get(str(appid)) + compat_mapping = verify_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}).get(str(appid)) logger.debug(f"[DEBUG] Verification: AppID {appid} -> {compat_mapping}") return True diff --git a/jackify/backend/services/automated_prefix_workflow.py b/jackify/backend/services/automated_prefix_workflow.py index c59bd29..c9ad0b1 100644 --- a/jackify/backend/services/automated_prefix_workflow.py +++ b/jackify/backend/services/automated_prefix_workflow.py @@ -133,6 +133,17 @@ class WorkflowMixin: """ logger.info("Starting proven working automated prefix creation workflow") + if download_dir is None: + try: + from jackify.backend.handlers.path_handler import PathHandler + ini_path = Path(modlist_install_dir) / 'ModOrganizer.ini' + dl_str = PathHandler().get_download_directory_linux_path(ini_path) + if dl_str: + download_dir = Path(dl_str) + logger.debug(f"Resolved download_dir from ini: {download_dir}") + except Exception as e: + logger.debug(f"Could not resolve download_dir from ini: {e}") + try: conflict_result = self.handle_existing_shortcut_conflict( shortcut_name, diff --git a/jackify/backend/services/diagnostic_service.py b/jackify/backend/services/diagnostic_service.py new file mode 100644 index 0000000..0969fba --- /dev/null +++ b/jackify/backend/services/diagnostic_service.py @@ -0,0 +1,326 @@ +""" +Diagnostic bundle service - collects logs, system info, and prefix records +into a tar.gz for support reporting. +""" + +import json +import logging +import os +import platform +import shutil +import subprocess +import tarfile +import tempfile +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +def build_bundle(output_dir: Optional[Path] = None) -> Path: + """ + Collect logs, system info, and per-prefix component records into a tar.gz. + Returns the path to the created bundle file. + """ + from jackify.shared.paths import get_jackify_logs_dir, get_jackify_data_dir + from jackify import __version__ + + if output_dir is None: + output_dir = get_jackify_data_dir() / "DiagnosticBundles" + output_dir.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + bundle_name = f"jackify_diagnostic_{timestamp}.tar.gz" + bundle_path = output_dir / bundle_name + + with tempfile.TemporaryDirectory() as staging_dir: + staging = Path(staging_dir) + + # System info + _write_text(staging / "system_info.txt", _collect_system_info(__version__)) + + # Logs + logs_dir = get_jackify_logs_dir() + log_staging = staging / "logs" + log_staging.mkdir() + cutoff = datetime.now().timestamp() - timedelta(days=7).total_seconds() + if logs_dir.is_dir(): + for log_file in sorted(logs_dir.glob("*.log*")): + if log_file.is_file() and log_file.stat().st_mtime >= cutoff: + try: + shutil.copy2(log_file, log_staging / log_file.name) + except Exception as exc: + logger.debug("Could not copy log %s: %s", log_file.name, exc) + + # Config files (credentials scrubbed) + _collect_config_files(staging) + + # Per-prefix component records + _collect_component_records(staging) + + # Modlist shortcut info + _collect_modlist_info(staging) + + with tarfile.open(bundle_path, "w:gz") as tar: + tar.add(staging_dir, arcname="jackify_diagnostic") + + logger.info("Diagnostic bundle written: %s", bundle_path) + return bundle_path + + +def _collect_system_info(version: str) -> str: + lines = [ + f"Jackify version: {version}", + f"Date: {datetime.now().isoformat()}", + f"Kernel: {platform.release()}", + f"Machine: {platform.machine()}", + "", + ] + + _append_engine_info(lines) + + # Distro + try: + import distro + lines.append(f"Distro: {distro.name(pretty=True)}") + except ImportError: + try: + lines.append(f"Distro: {platform.freedesktop_os_release().get('PRETTY_NAME', 'unknown')}") + except Exception: + lines.append("Distro: unknown") + + # glibc + try: + glibc = platform.libc_ver() + lines.append(f"glibc: {glibc[0]} {glibc[1]}") + except Exception: + lines.append("glibc: unknown") + + # GPU + try: + gpu_out = subprocess.check_output( + ["lspci", "-mm"], + stderr=subprocess.DEVNULL, + timeout=5, + text=True, + ) + gpu_lines = [l for l in gpu_out.splitlines() if "VGA" in l or "3D" in l or "Display" in l] + for gl in gpu_lines[:2]: + lines.append(f"GPU: {gl.strip()}") + except Exception: + lines.append("GPU: unavailable (lspci not found)") + + lines.append("") + + # Steam type + _append_steam_info(lines) + + return "\n".join(lines) + + +def _append_steam_info(lines: list) -> None: + flatpak_steam = Path.home() / ".var/app/com.valvesoftware.Steam" + native_steam = Path.home() / ".local/share/Steam" + + if flatpak_steam.is_dir(): + lines.append("Steam: Flatpak") + elif native_steam.is_dir(): + lines.append("Steam: Native") + else: + lines.append("Steam: not detected") + + # Proton versions — official builds in steamapps/common, community builds in compatibilitytools.d + proton_scan = [ + (native_steam / "steamapps/common", "valve"), + (flatpak_steam / "data/Steam/steamapps/common", "valve"), + (native_steam / "compatibilitytools.d", "community"), + (flatpak_steam / "data/Steam/compatibilitytools.d", "community"), + (Path.home() / ".steam/root/compatibilitytools.d", "community"), + ] + proton_versions = [] + seen = set() + for root, source in proton_scan: + if not root.is_dir(): + continue + for entry in sorted(root.iterdir()): + if entry.is_dir() and entry.name not in seen and "proton" in entry.name.lower(): + seen.add(entry.name) + proton_versions.append((entry.name, source)) + + if proton_versions: + lines.append("Proton versions:") + for name, source in sorted(proton_versions): + lines.append(f" {name} ({source})") + else: + lines.append("Proton versions: none found") + + +def _append_engine_info(lines: list) -> None: + try: + from jackify.backend.services.tool_registry import get_active_engine_id, ENGINE_TOOL_IDS, _read_manifest + active = get_active_engine_id() + lines.append(f"Active engine: {active}") + for tool_id in ENGINE_TOOL_IDS: + try: + manifest = _read_manifest(tool_id) + installed_version = manifest.get("installed_version") + if installed_version: + lines.append(f" {tool_id}: {installed_version}") + else: + lines.append(f" {tool_id}: not installed") + except Exception: + lines.append(f" {tool_id}: unknown") + except Exception as e: + lines.append(f"Engine info: unavailable ({e})") + lines.append("") + + +_CREDENTIAL_KEYS = {"nexus_api_key", "api_key", "access_token", "refresh_token", "token"} +_EXCLUDED_CONFIG_FILES = {"nexus-oauth.json"} + + +def _collect_config_files(staging: Path) -> None: + """Copy ~/.config/jackify files, excluding credential files and scrubbing credential fields.""" + config_dir = Path.home() / ".config" / "jackify" + if not config_dir.is_dir(): + return + + cfg_staging = staging / "config" + cfg_staging.mkdir() + + for cfg_file in sorted(config_dir.iterdir()): + if not cfg_file.is_file(): + continue + if cfg_file.name in _EXCLUDED_CONFIG_FILES: + continue + if cfg_file.suffix == ".json": + try: + data = json.loads(cfg_file.read_text(encoding="utf-8")) + _scrub_credentials(data) + (cfg_staging / cfg_file.name).write_text( + json.dumps(data, indent=2), encoding="utf-8" + ) + continue + except Exception as exc: + logger.debug("Could not parse %s for scrubbing: %s", cfg_file.name, exc) + try: + shutil.copy2(cfg_file, cfg_staging / cfg_file.name) + except Exception as exc: + logger.debug("Could not copy config file %s: %s", cfg_file.name, exc) + + +def _scrub_credentials(obj: object) -> None: + """Recursively replace credential field values with '[REDACTED]' in-place.""" + if isinstance(obj, dict): + for key in list(obj.keys()): + if key.startswith("nexus_premium_cache_"): + del obj[key] + elif any(cred in key.lower() for cred in _CREDENTIAL_KEYS): + if obj[key] is not None: + obj[key] = "[REDACTED]" + else: + _scrub_credentials(obj[key]) + elif isinstance(obj, list): + for item in obj: + _scrub_credentials(item) + + +def _collect_component_records(staging: Path) -> None: + """Find jackify_components.json files in known prefix locations and copy them.""" + steam_compat = Path.home() / ".steam/root/steamapps/compatdata" + flatpak_compat = Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/compatdata" + + cutoff = datetime.now().timestamp() - timedelta(days=30).total_seconds() + found = [] + for base in (steam_compat, flatpak_compat): + if not base.is_dir(): + continue + try: + for pfx_dir in base.iterdir(): + record = pfx_dir / "pfx" / "jackify_components.json" + if record.is_file() and record.stat().st_mtime >= cutoff: + found.append((pfx_dir.name, record)) + except PermissionError: + pass + + if not found: + return + + comp_staging = staging / "component_records" + comp_staging.mkdir() + for appid, record_path in found: + dest = comp_staging / f"jackify_components_{appid}.json" + try: + shutil.copy2(record_path, dest) + except Exception as exc: + logger.debug("Could not copy component record for %s: %s", appid, exc) + + +def _collect_modlist_info(staging: Path) -> None: + """Collect installed modlist details from Steam shortcuts.vdf and config.vdf.""" + try: + from jackify.backend.services.install_verifier_service import _load_verifier + vmod = _load_verifier() + modlists = vmod.discover_installed_modlists() + except Exception as exc: + logger.debug("Could not discover modlists for bundle: %s", exc) + return + + if not modlists: + return + + # Build a lookup of launch options keyed by unsigned appid from shortcuts.vdf + launch_opts: dict = {} + try: + for vdf_path in vmod._find_shortcuts_vdf_paths(): + for sc in vmod._parse_shortcuts_vdf(vdf_path): + raw = sc.get("appid", sc.get("AppID", sc.get("appId"))) + if raw is None: + continue + try: + unsigned = str(vmod._signed_to_unsigned(int(raw))) + except Exception: + continue + lo = sc.get("LaunchOptions", sc.get("launchoptions", "")) + if lo: + launch_opts[unsigned] = lo + except Exception as exc: + logger.debug("Could not read launch options from shortcuts.vdf: %s", exc) + + # Read config.vdf once for Proton mappings + proton_map: dict = {} + try: + for root in vmod._find_steam_roots(): + cfg = root / "config" / "config.vdf" + if cfg.is_file(): + content = cfg.read_text(encoding="utf-8", errors="replace") + for m in modlists: + appid = m.get("appid", "") + if appid and appid not in proton_map: + tool = vmod._vdf_extract_compat_tool(content, appid) + if tool: + proton_map[appid] = tool + break + except Exception as exc: + logger.debug("Could not read Proton versions from config.vdf: %s", exc) + + records = [] + for m in modlists: + appid = m.get("appid", "") + records.append({ + "name": m.get("name", "Unknown"), + "appid": appid, + "install_dir": str(m.get("modlist_dir", "")), + "game_type": m.get("game_type", "unknown"), + "proton_version": proton_map.get(appid), + "launch_options": launch_opts.get(appid), + }) + + _write_text(staging / "modlists.json", json.dumps(records, indent=2)) + + +def _write_text(path: Path, content: str) -> None: + try: + path.write_text(content, encoding="utf-8") + except Exception as exc: + logger.debug("Could not write %s: %s", path, exc) diff --git a/jackify/backend/services/download_watcher_service.py b/jackify/backend/services/download_watcher_service.py index 35b9d2d..ddaee2a 100644 --- a/jackify/backend/services/download_watcher_service.py +++ b/jackify/backend/services/download_watcher_service.py @@ -3,13 +3,12 @@ Watches a directory for newly downloaded files and matches them against a list of pending manual download items by lax filename comparison. """ -import os import re import time import logging from dataclasses import dataclass, field from pathlib import Path -from threading import Thread, Event +from threading import Thread, Event, Lock from typing import Callable, Optional logger = logging.getLogger(__name__) @@ -29,6 +28,11 @@ class DownloadWatcherService: Caller sets pending_items (list of dicts with at least 'file_name') and registers an on_candidate callback that receives (Path, dict) when a potential match is detected (after debounce, before hash validation). + + Detection strategy: every scan checks every non-temp file against pending + items. Files currently being debounced are skipped to avoid duplicate + threads. When debounce completes (pass or fail), the path is cleared from + the in-flight set so the next scan can re-detect it if still pending. """ def __init__(self, config: WatcherConfig, on_candidate: Callable[[Path, dict], None]): @@ -38,8 +42,8 @@ class DownloadWatcherService: self._pending_exact: list[tuple[str, dict]] = [] self._stop_event = Event() self._thread: Optional[Thread] = None - # Track known files so we only react to new/changed ones - self._known: dict[Path, float] = {} + self._debouncing: set[Path] = set() + self._debouncing_lock = Lock() def set_pending_items(self, items: list[dict]) -> None: """Replace the pending items list. Thread-safe for simple list swap.""" @@ -77,17 +81,11 @@ class DownloadWatcherService: for path in entries: if not path.is_file(): continue - # Skip browser temp files if path.suffix in ('.part', '.crdownload', '.tmp'): continue - try: - mtime = path.stat().st_mtime - except OSError: - continue - prev_mtime = self._known.get(path) - if prev_mtime == mtime: - continue - self._known[path] = mtime + with self._debouncing_lock: + if path in self._debouncing: + continue self._check_candidate(path) except OSError as e: logger.debug(f"Watcher scan error on {watch_dir}: {e}") @@ -100,15 +98,14 @@ class DownloadWatcherService: logger.debug(f"Candidate exact match: {path.name}") self._debounce_and_emit(path, item) return - # Some modlist metadata stores filenames with a leading dot that browsers - # strip when saving the download. Match against the stripped expected name. + # Leading-dot normalisation: browsers strip a leading dot from filenames. for expected_name, item in self._pending_exact: if expected_name.lstrip('.') == candidate_name: logger.debug(f"Candidate dot-normalized match: {path.name} -> {expected_name}") self._debounce_and_emit(path, item) return - # Some modlist metadata stores filenames with a leading numeric prefix - # (e.g. "1_filename.zip") that is absent from the browser-saved file. + # Numeric-prefix normalisation: engine metadata may include a leading + # numeric prefix (e.g. "1_filename.zip") absent from the downloaded file. for expected_name, item in self._pending_exact: stripped = re.sub(r'^\d+_', '', expected_name) if stripped != expected_name and stripped == candidate_name: @@ -117,30 +114,64 @@ class DownloadWatcherService: return def _debounce_and_emit(self, path: Path, item: dict) -> None: + with self._debouncing_lock: + self._debouncing.add(path) + + expected_size = 0 + try: + expected_size = int(item.get('expected_size', 0) or 0) + except (TypeError, ValueError): + expected_size = 0 + def _wait_and_emit(): - prev_size = -1 - stable_count = 0 - needed = max(1, int(self._config.debounce_seconds / 0.5)) - for _ in range(needed * 4): # max ~2× debounce time - if self._stop_event.is_set(): - return - time.sleep(0.5) - try: - size = path.stat().st_size - except OSError: - return - if size == prev_size: - stable_count += 1 - if stable_count >= needed: - break - else: - stable_count = 0 - prev_size = size - if path.exists(): - self._on_candidate(path, item) + became_stable = False + try: + prev_size = -1 + stable_count = 0 + needed = max(1, int(self._config.debounce_seconds / 0.5)) + for _ in range(needed * 4): + if self._stop_event.is_set(): + return + time.sleep(0.5) + try: + size = path.stat().st_size + except OSError: + return + # A slow/in-progress download can hold a constant size for the + # debounce window (initial throttle, network stall) and look + # stable while still incomplete. Validating it prematurely fails + # the hash, reverts the item to pending, and triggers a duplicate + # browser tab. Hold off until the file reaches its known size. + if expected_size > 0 and size != expected_size: + stable_count = 0 + prev_size = size + continue + if size == prev_size: + stable_count += 1 + if stable_count >= needed: + became_stable = True + break + else: + stable_count = 0 + prev_size = size + # Only validate if the file stopped growing. If still downloading, + # release the debounce lock so the next scan can retry once it finishes. + if became_stable and path.exists(): + self._on_candidate(path, item) + # Path stays in _debouncing until release_path() is called by the + # manager after validation completes, preventing repeated re-fires. + finally: + if not became_stable: + with self._debouncing_lock: + self._debouncing.discard(path) Thread(target=_wait_and_emit, daemon=True, name=f'Debounce-{path.name[:20]}').start() + def release_path(self, path: Path) -> None: + """Allow the watcher to re-detect a path after validation completes.""" + with self._debouncing_lock: + self._debouncing.discard(path) + def _watch_loop(self) -> None: while not self._stop_event.is_set(): self._scan() diff --git a/jackify/backend/services/engine_invoker.py b/jackify/backend/services/engine_invoker.py new file mode 100644 index 0000000..c673699 --- /dev/null +++ b/jackify/backend/services/engine_invoker.py @@ -0,0 +1,188 @@ +""" +Engine Invoker + +Resolves the active install engine and builds the appropriate subprocess command. +Keeps engine-specific CLI differences isolated from install workflow code. +""" +import logging +import os +from typing import List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +def get_active_engine_id() -> str: + from jackify.backend.services.tool_registry import get_active_engine_id as _get + return _get() + + +def is_clf3_active() -> bool: + return get_active_engine_id() == "clf3" + + +def ensure_engine_available(engine_id: str = "jackify-engine") -> Tuple[bool, str]: + """ + Check that the given engine binary is present and download it if not. + Returns (True, path) on success, (False, error_message) on failure. + Call this at startup before the first install attempt. + """ + from jackify.backend.services.tool_registry import ToolRegistry + path = get_engine_path(engine_id) + if path: + return True, path + logger.info("Engine %s not found, attempting download via Tools Hub", engine_id) + ok, msg = ToolRegistry().install(engine_id) + if not ok: + return False, msg + path = get_engine_path(engine_id) + if not path: + return False, f"{engine_id} downloaded but binary not found after install" + return True, path + + +def get_engine_path(engine_id: str) -> Optional[str]: + """Return the filesystem path to the engine binary for the given engine_id.""" + from jackify.backend.services.tool_registry import ToolRegistry + path = ToolRegistry().get_binary_path(engine_id) + if path and path.is_file(): + return str(path) + + if engine_id == "jackify-engine": + from jackify.backend.core.modlist_operations import get_jackify_engine_path + return get_jackify_engine_path() + + logger.warning("Engine binary not found for engine_id=%s", engine_id) + return None + + +def get_active_engine_path() -> Optional[str]: + """Return the binary path for the currently active engine.""" + return get_engine_path(get_active_engine_id()) + + +def resolve_game_dir(game_type: Optional[str], modlist_path: Optional[str] = None) -> Optional[str]: + """ + Resolve the vanilla game installation directory for CLF3's --game argument. + Searches Steam and Heroic-managed stores in order. + Returns None if the path cannot be determined. + Use resolve_game_location() when the store identity is also needed. + """ + result = resolve_game_location(game_type) + return result[0] if result else None + + +def resolve_game_location(game_type: Optional[str]) -> Optional[tuple]: + """ + Return (path_str, store) for the detected game installation, or None. + store is one of: 'steam', 'gog', 'epic', 'unknown'. + """ + if not game_type: + return None + try: + from jackify.backend.handlers.vanilla_game_finder import VanillaGameFinder + result = VanillaGameFinder().find(game_type) + if result: + path, store = result + return str(path), store + except Exception as e: + logger.debug("Game dir detection failed for %s: %s", game_type, e) + return None + + +def build_install_command( + engine_id: str, + engine_path: str, + wabbajack: str, + install_dir: str, + downloads_dir: str, + game_dir: Optional[str] = None, + install_mode: str = "online", + debug: bool = False, +) -> List[str]: + """Build the subprocess install command for the given engine.""" + if engine_id == "clf3": + return _build_clf3_command(engine_path, wabbajack, install_dir, downloads_dir, game_dir) + return _build_jackify_engine_command(engine_path, wabbajack, install_dir, downloads_dir, install_mode, debug, game_dir) + + +def _build_jackify_engine_command( + engine_path: str, + wabbajack: str, + install_dir: str, + downloads_dir: str, + install_mode: str, + debug: bool, + game_dir: Optional[str] = None, +) -> List[str]: + cmd = [engine_path, "install", "--show-file-progress"] + if wabbajack.endswith(".wabbajack") and os.path.isfile(wabbajack): + cmd += ["-w", wabbajack] + else: + cmd += ["-m", wabbajack] + cmd += ["-o", install_dir, "-d", downloads_dir] + if game_dir: + cmd += ["-g", game_dir] + if debug: + cmd.append("--debug") + return cmd + + +def _read_resource_settings() -> dict: + """Read resource_settings.json from the Jackify config dir; return empty dict on any failure.""" + try: + from jackify.shared.paths import get_jackify_config_dir + import json + path = get_jackify_config_dir() / "resource_settings.json" + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +def _clf3_default_workers() -> int: + """Default worker count matching jackify-engine: full cpu_count.""" + import multiprocessing + return max(1, multiprocessing.cpu_count() or 4) + + +def _build_clf3_command( + engine_path: str, + wabbajack: str, + install_dir: str, + downloads_dir: str, + game_dir: Optional[str] = None, +) -> List[str]: + # Positional order: + # CLF3 performs its own game detection (Steam + Heroic) with file verification. + # game_dir is reserved for explicit edge-case overrides only. + cmd = [engine_path, "install", "--jackify"] + if os.environ.get("JACKIFY_CLF3_VERBOSE"): + cmd.append("--verbose") + logger.info("CLF3 verbose mode enabled (JACKIFY_CLF3_VERBOSE)") + if game_dir: + cmd += ["--game", game_dir] + + res = _read_resource_settings() + default = _clf3_default_workers() + + def _tasks(key: str) -> int: + val = res.get(key, {}).get("MaxTasks", 0) + return val if val > 0 else default + + concurrent = _tasks("Downloads") + install_workers = _tasks("Installer") + sevenzip_workers = _tasks("File Extractor") + + cmd += [ + "--concurrent", str(concurrent), + "--install-workers", str(install_workers), + "--sevenzip-workers", str(sevenzip_workers), + ] + logger.debug( + "CLF3 resource flags: concurrent=%d install_workers=%d sevenzip_workers=%d (from %s)", + concurrent, install_workers, sevenzip_workers, + "resource_settings.json" if res else "default (cpu_count)", + ) + + cmd += [wabbajack, downloads_dir, install_dir] + return cmd diff --git a/jackify/backend/services/install_verifier_service.py b/jackify/backend/services/install_verifier_service.py new file mode 100644 index 0000000..d9ba622 --- /dev/null +++ b/jackify/backend/services/install_verifier_service.py @@ -0,0 +1,48 @@ +"""Service for running verify_install.py from Jackify workflows.""" + +import importlib.util +import logging +import sys +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +_BUNDLED_PATH = Path(__file__).parent.parent.parent / "tools" / "verify_install.py" + + +def _load_verifier(): + spec = importlib.util.spec_from_file_location("_verify_install_bundled", _BUNDLED_PATH) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot locate bundled verify_install.py at {_BUNDLED_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules.setdefault("_verify_install_bundled", module) + spec.loader.exec_module(module) + return module + + +def resolve_pfx_for_appid(appid: str) -> Optional[Path]: + """Resolve the Proton prefix path for a Steam AppID.""" + if not appid: + return None + steam_roots = [ + Path.home() / ".steam" / "steam", + Path.home() / ".local" / "share" / "Steam", + Path.home() / ".steam" / "root", + Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam", + ] + for root in steam_roots: + pfx = root / "steamapps" / "compatdata" / str(appid) / "pfx" + if pfx.is_dir(): + return pfx + return None + + +def run_install_verification(pfx: Path, modlist_dir: Path, game_type: str, appid: str = "", modlist_name: str = ""): + """Run the install verifier and return a Results object, or None on failure.""" + try: + verifier = _load_verifier() + return verifier.run_verification(pfx, modlist_dir, game_type, appid, modlist_name) + except Exception as e: + logger.warning("Install verifier failed: %s", e, exc_info=True) + raise diff --git a/jackify/backend/services/manual_download_manager.py b/jackify/backend/services/manual_download_manager.py index 687e994..be1d6f2 100644 --- a/jackify/backend/services/manual_download_manager.py +++ b/jackify/backend/services/manual_download_manager.py @@ -119,6 +119,7 @@ class ManualDownloadManager(ManualDownloadManagerApiMixin, ManualDownloadManager self._startup_precheck_pending = 0 self._run_id = f"mdl-{int(time.time())}-{id(self) % 10000}" self._last_progress_log_completed = -1 + self._last_browser_open: dict[str, float] = {} # file_name -> monotonic timestamp additional = [modlist_download_dir] if modlist_download_dir != watch_directory else [] config = WatcherConfig(watch_directory=watch_directory, additional_dirs=additional) diff --git a/jackify/backend/services/manual_download_manager_api_mixin.py b/jackify/backend/services/manual_download_manager_api_mixin.py index 14cb026..cb6feac 100644 --- a/jackify/backend/services/manual_download_manager_api_mixin.py +++ b/jackify/backend/services/manual_download_manager_api_mixin.py @@ -123,8 +123,12 @@ class ManualDownloadManagerApiMixin: with self._lock: for item in self._items: if item.file_name == file_name and item.status not in ('complete',): + # Only free the browser slot if this item actually held one. + # 'pending' items have no slot; 'validating' items still hold + # the slot counted when they entered 'browser_opened'. + had_slot = item.status in ('browser_opened', 'validating') item.status = 'deferred' - if self._active_tabs > 0: + if had_slot and self._active_tabs > 0: self._active_tabs -= 1 item_to_notify = item break @@ -133,6 +137,11 @@ class ManualDownloadManagerApiMixin: self._open_next_tabs() self._check_all_done() + def force_rescan(self) -> None: + """Re-ingest existing files immediately (Scan Now button).""" + self._diag("MDL-1026", "Force rescan requested by user") + self._ingest_existing_files() + def set_concurrent_limit(self, limit: int) -> None: with self._lock: self._limit = max(1, min(5, limit)) diff --git a/jackify/backend/services/manual_download_manager_runtime_mixin.py b/jackify/backend/services/manual_download_manager_runtime_mixin.py index 2e9cc1a..8e27743 100644 --- a/jackify/backend/services/manual_download_manager_runtime_mixin.py +++ b/jackify/backend/services/manual_download_manager_runtime_mixin.py @@ -4,8 +4,10 @@ from __future__ import annotations import json import logging +import os import re import subprocess +import time from pathlib import Path from typing import Optional @@ -84,6 +86,8 @@ class ManualDownloadManagerRuntimeMixin: return item return None + _BROWSER_OPEN_COOLDOWN = 30.0 # seconds before the same file's URL may be re-opened + def _open_browser(self, item: DownloadItem) -> tuple[bool, Optional[str]]: url = item.nexus_url if not url: @@ -91,6 +95,16 @@ class ManualDownloadManagerRuntimeMixin: logger.warning(f"{msg}: {item.file_name}") return False, msg + now = time.monotonic() + last = self._last_browser_open.get(item.file_name, 0.0) + if now - last < self._BROWSER_OPEN_COOLDOWN: + remaining = int(self._BROWSER_OPEN_COOLDOWN - (now - last)) + logger.warning( + f"Suppressed duplicate browser open for {item.file_name} " + f"(cooldown: {remaining}s remaining)" + ) + return True, None + # Linux desktop launch fallbacks. xdg-open should cover most environments, # but keep alternates for distributions where handlers differ. launch_cmds = ( @@ -99,6 +113,14 @@ class ManualDownloadManagerRuntimeMixin: ['sensible-browser', url], ) + # Strip AppImage library path overrides before launching external processes. + # Inheriting LD_LIBRARY_PATH causes xdg-open/kde-open to load bundled Qt + # libs, which produces symbol version mismatches on distributions that ship + # a different Qt version than the one bundled in the AppImage. + clean_env = os.environ.copy() + for var in ('LD_LIBRARY_PATH', 'LD_PRELOAD'): + clean_env.pop(var, None) + launch_errors: list[str] = [] for cmd in launch_cmds: try: @@ -107,6 +129,7 @@ class ManualDownloadManagerRuntimeMixin: stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, start_new_session=True, + env=clean_env, ) except OSError as e: launch_errors.append(f"{cmd[0]} not available: {e}") @@ -116,10 +139,12 @@ class ManualDownloadManagerRuntimeMixin: rc = proc.wait(timeout=3) except subprocess.TimeoutExpired: # Launcher still running after handoff window; treat as success. + self._last_browser_open[item.file_name] = time.monotonic() logger.debug(f"Opened browser for: {item.file_name} via {cmd[0]}") return True, None if rc == 0: + self._last_browser_open[item.file_name] = time.monotonic() logger.debug(f"Opened browser for: {item.file_name} via {cmd[0]}") return True, None @@ -144,7 +169,7 @@ class ManualDownloadManagerRuntimeMixin: item = self._item_by_name(file_name) if item is None: reject_reason = "unknown_item" - elif item.status in ('complete', 'skipped'): + elif item.status in ('complete', 'skipped', 'deferred'): reject_reason = f"terminal_status:{item.status}" elif item.status == 'validating': reject_reason = "already_validating" @@ -224,23 +249,30 @@ class ManualDownloadManagerRuntimeMixin: item_to_notify = item completed_now = True else: - # Hash mismatch or validation error - revert to pending so the - # sliding window can re-open a browser tab and the watcher can - # re-validate if the user downloads the correct file. - item.status = 'pending' msg = result.error or f"Hash mismatch (got {result.computed_hash})" - item.error_message = msg - logger.warning(f"Validation failed for {file_name}: {msg}") - if had_browser_slot and self._active_tabs > 0: - self._active_tabs -= 1 - item_to_notify = item - validation_failed = True + # If the user deferred this item while validation was in-flight, + # skip_item already decremented _active_tabs and set status='deferred'. + # Preserve the defer; don't double-decrement or re-open a browser tab. + if item.status == 'deferred': + item_to_notify = item + validation_failed = True + else: + # Revert to pending so the sliding window can re-open a browser tab. + item.status = 'pending' + item.error_message = msg + logger.warning(f"Validation failed for {file_name}: {msg}") + if had_browser_slot and self._active_tabs > 0: + self._active_tabs -= 1 + item_to_notify = item + validation_failed = True if from_startup_precheck and self._startup_precheck_pending > 0: self._startup_precheck_pending -= 1 precheck_ready = self._startup_precheck_pending == 0 if item_to_notify is not None: self._notify(item_to_notify) + if result.file_path: + self._watcher.release_path(result.file_path) if completed_now: self._diag( "MDL-1021", diff --git a/jackify/backend/services/modlist_service.py b/jackify/backend/services/modlist_service.py index f11a936..03cba0c 100644 --- a/jackify/backend/services/modlist_service.py +++ b/jackify/backend/services/modlist_service.py @@ -158,9 +158,8 @@ class ModlistService(ModlistServiceInstallationMixin): logger.error(f"Failed to list modlists: {e}") raise - def configure_modlist_post_steam(self, context: ModlistContext, + def configure_modlist_post_steam(self, context: ModlistContext, progress_callback=None, - manual_steps_callback=None, completion_callback=None) -> bool: """Configure a modlist after Steam setup is complete. @@ -173,7 +172,6 @@ class ModlistService(ModlistServiceInstallationMixin): Args: context: Modlist context with updated app_id progress_callback: Optional callback for progress updates - manual_steps_callback: Called when manual steps needed completion_callback: Called when configuration is complete Returns: @@ -253,12 +251,12 @@ class ModlistService(ModlistServiceInstallationMixin): 'path': str(context.install_dir), 'mo2_exe_path': str(context.install_dir / 'ModOrganizer.exe'), 'resolution': getattr(context, 'resolution', None), - 'skip_confirmation': True, # Service layer should be non-interactive - 'manual_steps_completed': True, # Manual steps were done in GUI - 'appid': getattr(context, 'app_id', None), # Use updated app_id from Steam + 'skip_confirmation': True, + 'appid': getattr(context, 'app_id', None), 'engine_installed': getattr(context, 'engine_installed', False), # Path manipulation flag 'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None, 'modlist_source': getattr(context, 'modlist_source', None), + 'suppress_completion_banner': True, } debug_callback(f"Configuration context built: {config_context}") @@ -317,7 +315,11 @@ class ModlistService(ModlistServiceInstallationMixin): debug_callback("Calling run_modlist_configuration_phase") success = modlist_menu.run_modlist_configuration_phase(config_context) debug_callback(f"Configuration phase result: {success}") - + context.steam_restart_needed = config_context.get('steam_restart_needed', False) + context.mounts_app_name = config_context.get('mounts_app_name', '') + context.mounts_exe_path = config_context.get('mounts_exe_path', '') + context.mounts_dl_path = config_context.get('mounts_dl_path', '') + # Restore stdout before ENB detection and completion callback if original_stdout: sys.stdout = original_stdout @@ -405,20 +407,18 @@ class ModlistService(ModlistServiceInstallationMixin): return False - def configure_modlist(self, context: ModlistContext, - progress_callback=None, - manual_steps_callback=None, + def configure_modlist(self, context: ModlistContext, + progress_callback=None, completion_callback=None, output_callback=None) -> bool: """Configure a modlist after installation. - + Args: context: Modlist context progress_callback: Optional callback for progress updates - manual_steps_callback: Optional callback for manual steps completion_callback: Optional callback for completion output_callback: Optional callback for output/logging - + Returns: True if configuration successful, False otherwise """ @@ -438,15 +438,14 @@ class ModlistService(ModlistServiceInstallationMixin): 'path': str(context.install_dir), 'mo2_exe_path': str(context.install_dir / 'ModOrganizer.exe'), 'resolution': getattr(context, 'resolution', None), - 'skip_confirmation': True, # Service layer should be non-interactive - 'manual_steps_completed': False, - 'appid': getattr(context, 'app_id', None), # Fix: Include appid like other configuration paths + 'skip_confirmation': True, + 'appid': getattr(context, 'app_id', None), 'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None, } # DEBUG: Log what resolution we're passing - logger.info(f"DEBUG: config_context resolution = {config_context['resolution']}") - logger.info(f"DEBUG: context.resolution = {getattr(context, 'resolution', 'NOT_SET')}") + logger.info(f"config_context resolution = {config_context['resolution']}") + logger.info(f"context.resolution = {getattr(context, 'resolution', 'NOT_SET')}") # Run the complete configuration phase success = modlist_menu.run_modlist_configuration_phase(config_context) diff --git a/jackify/backend/services/modlist_service_installation.py b/jackify/backend/services/modlist_service_installation.py index 653fbee..13a34e8 100644 --- a/jackify/backend/services/modlist_service_installation.py +++ b/jackify/backend/services/modlist_service_installation.py @@ -300,13 +300,21 @@ class ModlistServiceInstallationMixin: output_callback(" - If problems persist, uninstall and reinstall Skyrim, then launch once to trigger the AE download.") output_callback(" - Note: Skyrim AE via Steam Family Sharing does not transfer DLC content.") if _ck_missing and output_callback: + _gt = context.get('game_type') or '' + if 'fallout4' in _gt.lower(): + _ck_name = "Fallout 4 Creation Kit" + _ck_search = "Fallout 4: Creation Kit" + else: + _ck_name = "Skyrim Special Edition Creation Kit" + _ck_search = "Skyrim Special Edition: Creation Kit" output_callback("") output_callback("[WARN] Creation Kit Files Missing") - output_callback(" This modlist requires the Skyrim Special Edition Creation Kit.") - output_callback(" - In Steam, search for 'Skyrim Special Edition: Creation Kit' and install it.") + output_callback(f" This modlist requires the {_ck_name}.") + output_callback(f" - In Steam, search for '{_ck_search}' and install it.") output_callback(" - Right-click it in Steam > Properties > Compatibility and set a Proton version.") output_callback(" - Click Play to launch the Creation Kit.") - output_callback(" - When asked whether to unzip Scripts.zip, select NO.") + if 'fallout4' not in _gt.lower(): + output_callback(" - When asked whether to unzip Scripts.zip, select NO.") output_callback(" - Once the Creation Kit opens successfully, close it.") output_callback(" - Re-run the modlist install in Jackify.") return False diff --git a/jackify/backend/services/native_steam_service.py b/jackify/backend/services/native_steam_service.py index 3779709..460289f 100644 --- a/jackify/backend/services/native_steam_service.py +++ b/jackify/backend/services/native_steam_service.py @@ -224,10 +224,19 @@ class NativeSteamService: try: # Create backup first if shortcuts_path.exists(): - backup_path = shortcuts_path.with_suffix(f".vdf.backup_{int(time.time())}") import shutil + import glob + backup_dir = shortcuts_path.parent / "backups" + backup_dir.mkdir(exist_ok=True) + backup_path = backup_dir / f"shortcuts_{int(time.time())}.bak" shutil.copy2(shortcuts_path, backup_path) logger.info(f"Created backup: {backup_path}") + existing = sorted(glob.glob(str(backup_dir / "shortcuts_*.bak"))) + for old in existing[:-5]: + try: + os.remove(old) + except Exception: + pass # Ensure parent directory exists shortcuts_path.parent.mkdir(parents=True, exist_ok=True) @@ -386,10 +395,19 @@ class NativeSteamService: return False # Create backup first - backup_path = config_path.with_suffix(f".vdf.backup_{int(time.time())}") import shutil + import glob + backup_dir = config_path.parent / "backups" + backup_dir.mkdir(exist_ok=True) + backup_path = backup_dir / f"config_{int(time.time())}.bak" shutil.copy2(config_path, backup_path) logger.info(f"Created backup: {backup_path}") + existing = sorted(glob.glob(str(backup_dir / "config_*.bak"))) + for old in existing[:-5]: + try: + os.remove(old) + except Exception: + pass # Read the file as text to avoid VDF library formatting issues with open(config_path, 'r', encoding='utf-8', errors='ignore') as f: diff --git a/jackify/backend/services/nexus_auth_service.py b/jackify/backend/services/nexus_auth_service.py index f3dc06c..4e2ac43 100644 --- a/jackify/backend/services/nexus_auth_service.py +++ b/jackify/backend/services/nexus_auth_service.py @@ -212,7 +212,7 @@ class NexusAuthService: Returns: Tuple of (valid, username_or_error) """ - return self.api_key_service.validate_api_key(api_key) + return self.api_key_service.validate_api_key_works(api_key) def ensure_valid_auth(self) -> Optional[str]: """ diff --git a/jackify/backend/services/nexus_premium_service.py b/jackify/backend/services/nexus_premium_service.py index 2b171ff..cc8520a 100644 --- a/jackify/backend/services/nexus_premium_service.py +++ b/jackify/backend/services/nexus_premium_service.py @@ -1,4 +1,5 @@ """Nexus Premium status detection service.""" +import hashlib import time import logging from typing import Tuple, Optional @@ -70,7 +71,8 @@ class NexusPremiumService: def _cache_key(self, token: str, is_oauth: bool = False) -> str: suffix = "oauth" if is_oauth else "apikey" - return f"nexus_premium_cache_{token[:8]}_{suffix}" + token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()[:12] + return f"nexus_premium_cache_{token_hash}_{suffix}" def _read_cache(self, token: str, is_oauth: bool = False) -> Optional[Tuple[bool, Optional[str]]]: try: diff --git a/jackify/backend/services/nxm_downloader.py b/jackify/backend/services/nxm_downloader.py new file mode 100644 index 0000000..418bc8b --- /dev/null +++ b/jackify/backend/services/nxm_downloader.py @@ -0,0 +1,118 @@ +"""NXM download pipeline: resolve CDN URL and save to modlist download directory.""" + +import logging +from pathlib import Path +from typing import Optional, Callable, Tuple + +import requests + +from jackify.backend.services.nxm_url import NxmUrl + +logger = logging.getLogger(__name__) + +_NEXUS_API_BASE = "https://api.nexusmods.com/v1" +_CHUNK_SIZE = 65536 + + +def get_nxm_download_url(nxm: NxmUrl, auth_token: str, auth_method: str = "api_key") -> Optional[str]: + """Resolve an NXM URL to a CDN download URL using the Nexus API. + + The key/expires from the NXM URL authorise the request for both Premium + and non-Premium accounts. + """ + url = ( + f"{_NEXUS_API_BASE}/games/{nxm.game}/mods/{nxm.mod_id}" + f"/files/{nxm.file_id}/download_link.json" + ) + if auth_method == "oauth": + headers = {"Authorization": f"Bearer {auth_token}", "User-Agent": "jackify"} + else: + headers = {"apikey": auth_token, "User-Agent": "jackify"} + + params: dict = {} + if nxm.key: + params["key"] = nxm.key + if nxm.expires: + params["expires"] = nxm.expires + + try: + resp = requests.get(url, headers=headers, params=params, timeout=30) + resp.raise_for_status() + data = resp.json() + if isinstance(data, list) and data: + cdn_url = data[0].get("URI") + logger.debug("Resolved NXM CDN URL for file %s", nxm.file_id) + return cdn_url + logger.warning("Nexus API returned empty download link list for file %s", nxm.file_id) + return None + except requests.HTTPError as e: + logger.error( + "Nexus API error resolving NXM URL (method=%s, status=%s): %s", + auth_method, e.response.status_code if e.response else "?", e, + ) + return None + except Exception as e: + logger.error("Unexpected error resolving NXM URL: %s", e) + return None + + +def resolve_mo2_download_dir(modlist_dir: Path) -> Optional[Path]: + """Read download_directory from ModOrganizer.ini and resolve to a Linux path. + + Returns None if the directory is not configured or cannot be resolved. + Delegates to PathHandler which handles all MO2 path formats correctly. + """ + from jackify.backend.handlers.path_handler import PathHandler + ini_path = modlist_dir / "ModOrganizer.ini" + if not ini_path.exists(): + logger.warning("ModOrganizer.ini not found at %s", ini_path) + return None + dl_str = PathHandler().get_download_directory_linux_path(ini_path) + if dl_str: + return Path(dl_str) + default = modlist_dir / "downloads" + logger.debug("No download_directory in ini, using default: %s", default) + return default + + +def download_nxm_file( + cdn_url: str, + download_dir: Path, + filename: str, + progress_callback: Optional[Callable[[int, int], None]] = None, +) -> Tuple[bool, str]: + """Download a file to the modlist download directory. + + Returns (success, message). + """ + try: + download_dir.mkdir(parents=True, exist_ok=True) + dest = download_dir / filename + + resp = requests.get(cdn_url, stream=True, timeout=60) + resp.raise_for_status() + + total = int(resp.headers.get("content-length", 0)) + downloaded = 0 + + with open(dest, "wb") as f: + for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE): + if chunk: + f.write(chunk) + downloaded += len(chunk) + if progress_callback and total > 0: + progress_callback(downloaded, total) + + logger.info("NXM download complete: %s (%d bytes)", dest.name, downloaded) + return True, f"Saved to {dest}" + + except Exception as e: + logger.error("NXM download failed: %s", e) + return False, str(e) + + +def filename_from_cdn_url(cdn_url: str, fallback: str) -> str: + """Extract a filename from a CDN URL, falling back to provided name.""" + path = cdn_url.split("?")[0].rstrip("/") + name = path.split("/")[-1] + return name if name else fallback diff --git a/jackify/backend/services/nxm_ipc.py b/jackify/backend/services/nxm_ipc.py new file mode 100644 index 0000000..b2fd9aa --- /dev/null +++ b/jackify/backend/services/nxm_ipc.py @@ -0,0 +1,73 @@ +"""Unix socket IPC for single-instance NXM URL routing. + +The running Jackify instance listens on a QLocalServer. A second instance +launched by the OS protocol handler connects, sends the nxm:// URL, and exits. +""" + +import logging +from typing import Optional + +from PySide6.QtCore import QObject, Signal +from PySide6.QtNetwork import QLocalServer, QLocalSocket + +logger = logging.getLogger(__name__) + +_SOCKET_NAME = "jackify-nxm-ipc" +_CONNECT_TIMEOUT_MS = 1000 + + +class NxmIpcServer(QObject): + """Listens for nxm:// URLs from secondary Jackify instances.""" + + url_received = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._server: Optional[QLocalServer] = None + + def start(self) -> bool: + QLocalServer.removeServer(_SOCKET_NAME) + self._server = QLocalServer(self) + self._server.newConnection.connect(self._on_connection) + if not self._server.listen(_SOCKET_NAME): + logger.warning("NXM IPC server failed to start: %s", self._server.errorString()) + return False + logger.debug("NXM IPC server listening on %s", _SOCKET_NAME) + return True + + def stop(self) -> None: + if self._server: + self._server.close() + QLocalServer.removeServer(_SOCKET_NAME) + self._server = None + + def _on_connection(self) -> None: + conn = self._server.nextPendingConnection() + if conn: + conn.readyRead.connect(lambda: self._read(conn)) + + def _read(self, conn: QLocalSocket) -> None: + data = bytes(conn.readAll()).decode(errors="replace").strip() + conn.disconnectFromServer() + if data.startswith("nxm://"): + logger.info("NXM IPC received URL: %s", data) + self.url_received.emit(data) + else: + logger.warning("NXM IPC received unexpected data: %r", data[:80]) + + +def send_to_running_instance(url: str) -> bool: + """Send an nxm:// URL to the running Jackify instance. + + Returns True if a running instance was found and the URL was delivered. + """ + socket = QLocalSocket() + socket.connectToServer(_SOCKET_NAME) + if not socket.waitForConnected(_CONNECT_TIMEOUT_MS): + return False + socket.write(url.encode()) + socket.flush() + socket.waitForBytesWritten(500) + socket.disconnectFromServer() + logger.debug("NXM URL handed off to running instance") + return True diff --git a/jackify/backend/services/nxm_protocol.py b/jackify/backend/services/nxm_protocol.py new file mode 100644 index 0000000..4f97a28 --- /dev/null +++ b/jackify/backend/services/nxm_protocol.py @@ -0,0 +1,124 @@ +"""NXM protocol handler registration. + +Updates (or creates) the Jackify .desktop file to include +x-scheme-handler/nxm in its MimeType, then registers it with xdg. +""" + +import logging +import os +import subprocess +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + +_DESKTOP_FILE = Path.home() / ".local" / "share" / "applications" / "com.jackify.app.desktop" +_NXM_MIME = "x-scheme-handler/nxm" + + +def ensure_nxm_registered() -> bool: + """Add nxm:// handler to the existing Jackify .desktop file if not present. + + Safe to call on every launch - no-ops when already registered. + Returns True on success. + """ + try: + if not _DESKTOP_FILE.exists(): + if not _create_desktop_file(): + return False + + content = _DESKTOP_FILE.read_text() + if _NXM_MIME in content: + logger.debug("nxm:// already registered in desktop file") + return True + + # Add nxm to existing MimeType= line + updated = False + lines = content.splitlines() + for i, line in enumerate(lines): + if line.strip().startswith("MimeType="): + if not line.rstrip().endswith(";"): + lines[i] = line.rstrip() + ";" + lines[i] = lines[i] + f"{_NXM_MIME};" + updated = True + break + + if not updated: + # No MimeType line - append one + lines.append(f"MimeType={_NXM_MIME};") + + _DESKTOP_FILE.write_text("\n".join(lines) + "\n") + logger.info("Added nxm:// to desktop file MimeType") + + _run_xdg_registration() + return True + + except Exception as e: + logger.warning("Failed to register nxm:// protocol: %s", e) + return False + + +def _create_desktop_file() -> bool: + """Create a minimal .desktop file with both jackify:// and nxm:// handlers.""" + try: + env = os.environ + is_appimage = ( + "APPIMAGE" in env or "APPDIR" in env or + (sys.argv[0] and sys.argv[0].endswith(".AppImage")) + ) + if is_appimage: + exec_path = env.get("APPIMAGE") or str(Path(sys.argv[0]).resolve()) + exec_line = f'Exec="{exec_path}" %u' + else: + src_dir = Path(__file__).resolve().parent.parent.parent.parent + exec_path = f'bash -c \'cd "{src_dir}" && "{sys.executable}" -m jackify.frontends.gui "$@"\' --' + exec_line = f"Exec={exec_path} %u" + + _DESKTOP_FILE.parent.mkdir(parents=True, exist_ok=True) + _DESKTOP_FILE.write_text( + "[Desktop Entry]\n" + "Type=Application\n" + "Name=Jackify\n" + "Comment=Wabbajack modlist manager for Linux\n" + f"{exec_line}\n" + "Icon=com.jackify.app\n" + "Terminal=false\n" + "Categories=Game;Utility;\n" + f"MimeType=x-scheme-handler/jackify;{_NXM_MIME};\n" + ) + logger.info("Created desktop file at %s", _DESKTOP_FILE) + return True + except Exception as e: + logger.warning("Failed to create desktop file: %s", e) + return False + + +def _run_xdg_registration() -> None: + apps_dir = _DESKTOP_FILE.parent + for cmd in [ + ["update-desktop-database", str(apps_dir)], + ["xdg-mime", "default", _DESKTOP_FILE.name, _NXM_MIME], + ["xdg-settings", "set", "default-url-scheme-handler", "nxm", _DESKTOP_FILE.name], + ]: + try: + subprocess.run(cmd, capture_output=True, timeout=10) + except Exception as e: + logger.debug("xdg command %s failed (non-fatal): %s", cmd[0], e) + + # mimeapps.list fallback for DEs that ignore xdg-settings + mimeapps = Path.home() / ".config" / "mimeapps.list" + try: + content = mimeapps.read_text() if mimeapps.exists() else "[Default Applications]\n" + if f"{_NXM_MIME}=" not in content: + if "[Default Applications]" not in content: + content = "[Default Applications]\n" + content + lines = content.split("\n") + for i, line in enumerate(lines): + if line.strip() == "[Default Applications]": + lines.insert(i + 1, f"{_NXM_MIME}={_DESKTOP_FILE.name}") + break + mimeapps.parent.mkdir(parents=True, exist_ok=True) + mimeapps.write_text("\n".join(lines)) + logger.info("Added nxm handler to mimeapps.list") + except Exception as e: + logger.debug("mimeapps.list update failed (non-fatal): %s", e) diff --git a/jackify/backend/services/nxm_session.py b/jackify/backend/services/nxm_session.py new file mode 100644 index 0000000..8d4a6e6 --- /dev/null +++ b/jackify/backend/services/nxm_session.py @@ -0,0 +1,97 @@ +"""NXM session state: remembers which modlist to route downloads to. + +Clears when the process exits. Not persisted to disk. +""" + +import logging +import re +from pathlib import Path +from typing import Optional, List, Dict + +logger = logging.getLogger(__name__) + +_remembered_modlist: Optional[str] = None + + +def get_remembered_modlist() -> Optional[str]: + return _remembered_modlist + + +def set_remembered_modlist(name: str) -> None: + global _remembered_modlist + _remembered_modlist = name + + +def clear_remembered_modlist() -> None: + global _remembered_modlist + _remembered_modlist = None + + +def detect_active_mo2_modlist(modlists: List[Dict]) -> Optional[Dict]: + """Return the modlist whose MO2 instance is currently running, or None. + + Scans live processes for ModOrganizer.exe and matches the install path + against the provided modlist list. + """ + try: + import psutil + except ImportError: + logger.debug("psutil not available, skipping MO2 process detection") + return None + + mo2_dirs: List[Path] = [] + try: + for proc in psutil.process_iter(["cmdline"]): + try: + cmdline = proc.info.get("cmdline") or [] + for arg in cmdline: + arg_str = str(arg) + if "ModOrganizer.exe" in arg_str: + resolved = _resolve_mo2_path(arg_str) + if resolved: + mo2_dirs.append(resolved) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + except Exception as e: + logger.debug("MO2 process scan failed: %s", e) + return None + + if not mo2_dirs: + return None + + matches = [] + for modlist in modlists: + ml_dir = Path(modlist.get("modlist_dir", "")).resolve() + for mo2_dir in mo2_dirs: + try: + if mo2_dir.resolve() == ml_dir: + matches.append(modlist) + break + except Exception: + continue + + if len(matches) == 1: + logger.debug("Active MO2 instance matched modlist: %s", matches[0].get("name")) + return matches[0] + + if len(matches) > 1: + logger.debug("Multiple active MO2 instances found, falling back to picker") + else: + logger.debug("MO2 process found but no modlist match for dirs: %s", mo2_dirs) + return None + + +def _resolve_mo2_path(arg: str) -> Optional[Path]: + """Extract and resolve the modlist directory from a ModOrganizer.exe cmdline arg.""" + # Wine path: Z:\path\to\modlist\ModOrganizer.exe + m = re.match(r"(?i)z:([\\/].+?)[\\/]ModOrganizer\.exe", arg) + if m: + linux_path = m.group(1).replace("\\", "/") + return Path(linux_path) + + # Raw Linux path: /path/to/modlist/ModOrganizer.exe + m = re.match(r"(/.+?)/ModOrganizer\.exe", arg) + if m: + return Path(m.group(1)) + + return None diff --git a/jackify/backend/services/nxm_url.py b/jackify/backend/services/nxm_url.py new file mode 100644 index 0000000..bc97827 --- /dev/null +++ b/jackify/backend/services/nxm_url.py @@ -0,0 +1,60 @@ +"""NXM URL parser. + +nxm://{game}/mods/{mod_id}/files/{file_id}?key=KEY&expires=TS&user_id=UID +""" + +from dataclasses import dataclass +from typing import Optional +from urllib.parse import urlparse, parse_qs + + +@dataclass +class NxmUrl: + game: str + mod_id: int + file_id: int + key: str + expires: str + user_id: Optional[str] = None + raw: str = "" + + @property + def display_name(self) -> str: + return f"{self.game} / mod {self.mod_id} / file {self.file_id}" + + +def parse_nxm_url(url: str) -> NxmUrl: + """Parse an nxm:// URL into its components. + + Raises ValueError if the URL is malformed. + """ + parsed = urlparse(url) + if parsed.scheme.lower() != "nxm": + raise ValueError(f"Not an NXM URL: {url}") + + game = parsed.netloc.lower() + parts = [p for p in parsed.path.strip("/").split("/") if p] + # Expected: ['mods', '{mod_id}', 'files', '{file_id}'] + if len(parts) < 4 or parts[0] != "mods" or parts[2] != "files": + raise ValueError(f"Unexpected NXM URL path: {parsed.path}") + + try: + mod_id = int(parts[1]) + file_id = int(parts[3]) + except ValueError: + raise ValueError(f"Non-integer mod/file ID in NXM URL: {url}") + + params = parse_qs(parsed.query) + key = params.get("key", [""])[0] + expires = params.get("expires", [""])[0] + user_id = params.get("user_id", [None])[0] + + return NxmUrl( + game=game, + mod_id=mod_id, + file_id=file_id, + key=key, + expires=expires, + user_id=user_id, + raw=url, + ) diff --git a/jackify/backend/services/steamgriddb_service.py b/jackify/backend/services/steamgriddb_service.py index ce193f5..cc56c64 100644 --- a/jackify/backend/services/steamgriddb_service.py +++ b/jackify/backend/services/steamgriddb_service.py @@ -153,12 +153,13 @@ def detect_game_type_from_modlist(modlist_dir: str) -> Optional[str]: return None -def fetch_artwork(game_type: str, dest_dir: Path) -> int: +def fetch_artwork(game_type: str, dest_dir: Path, skip_existing: bool = False) -> int: """ Fetch top-voted artwork for game_type from SteamGridDB into dest_dir. Returns the number of images successfully downloaded. - dest_dir must already exist. + dest_dir must already exist. When skip_existing is True, slots where the + file already exists in dest_dir are skipped. """ steam_appid = GAME_STEAM_APP_IDS.get(game_type) if not steam_appid: @@ -168,12 +169,14 @@ def fetch_artwork(game_type: str, dest_dir: Path) -> int: api_key = _get_api_key() downloaded = 0 for endpoint, query, filename in _ARTWORK_SLOTS: + dest_path = dest_dir / filename + if skip_existing and dest_path.exists(): + continue data = _api_get(f"{endpoint}/steam/{steam_appid}?{query}", api_key) if not data or not data.get("success") or not data.get("data"): logger.debug(f"No {endpoint} results for {game_type} ({steam_appid})") continue image_url = data["data"][0]["url"] - dest_path = dest_dir / filename if _download(image_url, dest_path): logger.info(f"Downloaded {filename} for {game_type} from SteamGridDB") downloaded += 1 diff --git a/jackify/backend/services/tool_config_service.py b/jackify/backend/services/tool_config_service.py index b98b5d3..2c92a41 100644 --- a/jackify/backend/services/tool_config_service.py +++ b/jackify/backend/services/tool_config_service.py @@ -8,11 +8,13 @@ standalone operation for existing prefixes. Based on research into NaK's registry configuration (external reference only). """ +import json import logging import os import subprocess import tempfile import urllib.request +import zipfile from pathlib import Path from typing import Callable, Optional @@ -91,11 +93,11 @@ def _build_reg_content() -> str: return "\r\n".join(lines) -# .NET 9 SDK - direct installer, not available via winetricks. -# Synthesis runs on .NET 9; the SDK (not just runtime) is required for patcher compilation. +# .NET 9 SDK - ZIP distribution, extracted directly to avoid running an EXE under Wine. +# Synthesis requires the SDK (not just runtime) for patcher compilation. # Versions match Fluorine's confirmed-working prefix configuration. -_DOTNET9_SDK_URL = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.310/dotnet-sdk-9.0.310-win-x64.exe" -_DOTNET9_SDK_FILENAME = "dotnet-sdk-9.0.310-win-x64.exe" +_DOTNET9_SDK_URL = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.310/dotnet-sdk-9.0.310-win-x64.zip" +_DOTNET9_SDK_FILENAME = "dotnet-sdk-9.0.310-win-x64.zip" # .NET Desktop Runtime 10 - provides NETCore.App + WindowsDesktop.App 10.0.2. # Covers Synthesis patchers targeting .NET 10 runtime. @@ -122,42 +124,29 @@ def _install_dotnet9_sdk( log: Callable[[str], None], ) -> bool: """ - Download and install the .NET 9 SDK into the Wine prefix. - Cached to avoid re-downloading on subsequent runs. + Download and extract the .NET 9 SDK ZIP into the Wine prefix. + Uses the standalone ZIP distribution to avoid running an EXE under Wine. + Synthesis requires the full SDK (Roslyn compiler) for patcher compilation. """ try: from jackify.shared.paths import get_jackify_data_dir cache_dir = get_jackify_data_dir() / "cache" cache_dir.mkdir(parents=True, exist_ok=True) - installer = cache_dir / _DOTNET9_SDK_FILENAME + sdk_zip = cache_dir / _DOTNET9_SDK_FILENAME - if not installer.exists(): + if not sdk_zip.exists(): log(f"Downloading .NET 9 SDK ({_DOTNET9_SDK_FILENAME})...") - urllib.request.urlretrieve(_DOTNET9_SDK_URL, installer) + urllib.request.urlretrieve(_DOTNET9_SDK_URL, sdk_zip) log(".NET 9 SDK downloaded") else: - log(".NET 9 SDK installer already cached, skipping download") + log(".NET 9 SDK already cached, skipping download") - log("Installing .NET 9 SDK (this may take a few minutes)...") - env = os.environ.copy() - env["WINEPREFIX"] = str(prefix_path) - env["WINEDEBUG"] = "-all" - env["WINEDLLOVERRIDES"] = "mshtml=d;winemenubuilder.exe=d" - env["DISPLAY"] = env.get("DISPLAY", ":0") - - result = subprocess.run( - [wine_bin, str(installer), "/install", "/quiet", "/norestart"], - env=env, - capture_output=True, - text=True, - timeout=600, - ) - - if result.returncode not in (0, 3010): # 3010 = success, reboot required - log(f".NET 9 SDK installer exited with code {result.returncode}") - return False - - log(".NET 9 SDK installed successfully") + dest = prefix_path / "drive_c" / "Program Files" / "dotnet" + dest.mkdir(parents=True, exist_ok=True) + log("Extracting .NET 9 SDK...") + with zipfile.ZipFile(sdk_zip) as zf: + zf.extractall(dest) + log(".NET 9 SDK extracted successfully") return True except Exception as e: @@ -362,6 +351,7 @@ def apply_tool_config( log: Optional[Callable[[str], None]] = None, install_dotnet9_sdk: bool = False, install_fxc2_d3dcompiler: bool = False, + preserve_global_mscoree: bool = False, ) -> bool: """ Apply tool compatibility settings to the Wine prefix. @@ -399,20 +389,25 @@ def apply_tool_config( # Remove legacy global *mscoree=native from DllOverrides if present. # Old installs wrote this globally, which breaks .NET 9/10 bootstrap (Synthesis). # The targeted AppDefaults\SkyrimSE.exe entry written below replaces it. - try: - env_clean = os.environ.copy() - env_clean["WINEPREFIX"] = str(prefix_path) - env_clean["WINEDEBUG"] = "-all" - env_clean["DISPLAY"] = env_clean.get("DISPLAY", ":0") - subprocess.run( - [wine_bin, "reg", "delete", - "HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides", - "/v", "*mscoree", "/f"], - env=env_clean, capture_output=True, text=True, timeout=15, - ) - _log("Removed legacy global *mscoree override (if present)") - except Exception as e: - _log(f"Note: could not remove legacy mscoree entry (non-fatal): {e}") + # NSF/CSF modlists are the exception: NetScriptFramework's mixed-mode runtime needs the + # global override to host the CLR (the per-exe entry alone is insufficient), so it is kept. + if preserve_global_mscoree: + _log("Preserving global *mscoree=native (NSF/CSF modlist)") + else: + try: + env_clean = os.environ.copy() + env_clean["WINEPREFIX"] = str(prefix_path) + env_clean["WINEDEBUG"] = "-all" + env_clean["DISPLAY"] = env_clean.get("DISPLAY", ":0") + subprocess.run( + [wine_bin, "reg", "delete", + "HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides", + "/v", "*mscoree", "/f"], + env=env_clean, capture_output=True, text=True, timeout=15, + ) + _log("Removed legacy global *mscoree override (if present)") + except Exception as e: + _log(f"Note: could not remove legacy mscoree entry (non-fatal): {e}") reg_content = _build_reg_content() diff --git a/jackify/backend/services/tool_registry.py b/jackify/backend/services/tool_registry.py index 18dc9b5..ed75f69 100644 --- a/jackify/backend/services/tool_registry.py +++ b/jackify/backend/services/tool_registry.py @@ -1,16 +1,9 @@ """ -Third-party tool registry. +Third-party tool registry: install, update, downgrade, and uninstall. -Manages install, update, downgrade, and uninstall of independently-versioned -tools that Jackify either invokes directly (Tier 1) or makes available for users -to run from MO2 (Tier 2). - -Each tool stores a manifest at: - $jackify_data_dir/tools//manifest.json - -TTW_Linux_Installer is a special case: it has a pre-existing handler with its -own config keys. The registry reads those keys for status display and delegates -install/update to the existing handler rather than managing storage itself. +Tool state is stored at $jackify_data_dir/tools//manifest.json. +TTW_Linux_Installer installs into $jackify_data_dir/tools/ttw_installer/ and +delegates the download/extract to TTWInstallerHandler. """ import json @@ -32,7 +25,6 @@ logger = logging.getLogger(__name__) TOOLS_BASE_DIR = get_jackify_data_dir() / "tools" GITHUB_API = "https://api.github.com/repos/{repo}/releases/{ref}" - @dataclass class ToolDefinition: tool_id: str @@ -44,6 +36,14 @@ class ToolDefinition: executable_names: List[str] = field(default_factory=list) pinned_version: Optional[str] = None # None = always use latest can_uninstall: bool = True # False for tools Jackify hard-depends on + can_downgrade: bool = True # False for pinned tools where version must not change + is_engine: bool = False # Engine cards show Set Active instead of Launch + can_launch: bool = False # Tool has a launchable binary the user runs directly + nexus_mod_id: Optional[int] = None # Nexus mod ID; premium users download from Nexus first + nexus_game_domain: str = "site" # Nexus game domain for site-wide tools + nexus_file_filter: Optional[str] = None # Substring filter to pick the right Nexus file + hidden: bool = False # Set true in manifest to suppress display and installs + include_prereleases: bool = False # If True, newest release by date (inc. pre-releases) is used @dataclass @@ -58,15 +58,37 @@ class ToolStatus: @property def can_downgrade(self) -> bool: - prev_dir = TOOLS_BASE_DIR / self.definition.tool_id / "_previous" - return self.previous_version is not None and prev_dir.exists() + return ( + self.installed + and self.definition.can_downgrade + and self.definition.pinned_version is None + ) -# --------------------------------------------------------------------------- -# Tool catalogue -# --------------------------------------------------------------------------- - TOOL_DEFINITIONS: List[ToolDefinition] = [ + ToolDefinition( + tool_id="jackify-engine", + display_name="jackify-engine", + description="Native Wabbajack-matched file handler. The proven, stable engine for modlist installs.", + github_repo="Omni-guides/dev-jackify-engine", + asset_patterns=[r"jackify-engine.*linux.*x64.*\.tar\.gz", r"jackify-engine.*\.tar\.gz", r"jackify-engine.*\.zip"], + executable_names=["jackify-engine"], + tier=1, + can_uninstall=False, + is_engine=True, + ), + ToolDefinition( + tool_id="clf3", + display_name="CLF3", + description="Rust-based Wabbajack file handler. Faster installs, slightly slower modlist updates than jackify-engine.", + github_repo="SulfurNitride/CLF3", + asset_patterns=[r"clf3.*linux.*x86_64.*\.tar\.gz", r"clf3.*\.tar\.gz", r"clf3.*\.zip"], + executable_names=["clf3"], + tier=1, + can_uninstall=True, + is_engine=True, + include_prereleases=True, + ), ToolDefinition( tool_id="ttw_installer", display_name="TTW Linux Installer", @@ -75,58 +97,130 @@ TOOL_DEFINITIONS: List[ToolDefinition] = [ asset_patterns=[r"universal-mpi-installer.*\.(zip|tar\.gz)"], executable_names=["mpi_installer", "ttw_linux_gui"], tier=1, - can_uninstall=False, - ), - ToolDefinition( - tool_id="clf3", - display_name="CLF3", - description="Rust-based Wabbajack file handler. Planned as an experimental engine alternative.", - github_repo="SulfurNitride/CLF3", - asset_patterns=[r"clf3.*linux.*x86_64", r"clf3.*\.tar\.gz", r"clf3.*\.zip"], - executable_names=["clf3"], - tier=1, can_uninstall=True, - ), - ToolDefinition( - tool_id="fluorine", - display_name="Fluorine Manager", - description="Linux-native MO2 port with FUSE-based VFS and built-in Rootbuilder support.", - github_repo="SulfurNitride/Fluorine-Manager", - asset_patterns=[r"fluorine.*\.appimage", r"fluorine.*\.tar\.gz", r"fluorine.*\.zip"], - executable_names=["Fluorine", "fluorine"], - tier=2, - ), - ToolDefinition( - tool_id="bodyslide", - display_name="BodySlide (Linux Port)", - description="BodySlide and Outfit Studio ported to Linux. For body/outfit mesh conversion.", - github_repo="SulfurNitride/BodySlide-and-Outfit-Studio-Linux-Port", - asset_patterns=[r"bodyslide.*linux.*\.(appimage|tar\.gz|zip)", r".*bodyslide.*\.(tar\.gz|zip)"], - executable_names=["BodySlide", "BodySlide_x64"], - tier=2, + can_launch=True, + pinned_version="0.0.7", # must match TTW_INSTALLER_PINNED_VERSION in ttw_installer_handler.py + nexus_mod_id=1657, + nexus_file_filter="mpi", ), ToolDefinition( tool_id="radium", display_name="Radium Textures", - description="Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation.", + description="Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation. Run directly against mod files.", github_repo="SulfurNitride/Radium-Textures", asset_patterns=[r"radium.*linux.*x86_64", r"radium.*\.tar\.gz", r"radium.*\.zip"], executable_names=["radium", "radium-textures"], tier=2, + can_launch=True, + nexus_mod_id=1660, + nexus_file_filter="linux", ), ] _TOOL_MAP: Dict[str, ToolDefinition] = {t.tool_id: t for t in TOOL_DEFINITIONS} +ENGINE_TOOL_IDS: List[str] = [t.tool_id for t in TOOL_DEFINITIONS if t.is_engine] +_DEFAULT_ENGINE = "jackify-engine" +_ACTIVE_ENGINE_CONFIG_KEY = "active_engine" + + +def get_active_engine_id() -> str: + try: + from jackify.backend.handlers.config_handler import ConfigHandler + val = ConfigHandler().get(_ACTIVE_ENGINE_CONFIG_KEY, _DEFAULT_ENGINE) + return val if val in ENGINE_TOOL_IDS else _DEFAULT_ENGINE + except Exception: + return _DEFAULT_ENGINE + +def set_active_engine_id(tool_id: str) -> None: + if tool_id not in ENGINE_TOOL_IDS: + raise ValueError(f"Not an engine: {tool_id}") + try: + from jackify.backend.handlers.config_handler import ConfigHandler + cfg = ConfigHandler() + cfg.set(_ACTIVE_ENGINE_CONFIG_KEY, tool_id) + cfg.save_config() + except Exception as e: + logger.warning("Could not persist active engine selection: %s", e) + + +# -- remote manifest --------------------------------------------------------- +TOOL_MANIFEST_URL = "https://raw.githubusercontent.com/Omni-guides/Jackify/main/tools_manifest.json" +_BUNDLED_MANIFEST_PATH = Path(__file__).parent / "tools_manifest.json" + + +def _parse_manifest_entries(entries: list) -> Optional[List[ToolDefinition]]: + definitions = [] + for entry in entries: + try: + definitions.append(ToolDefinition( + tool_id=entry["tool_id"], + display_name=entry["display_name"], + description=entry["description"], + github_repo=entry["github_repo"], + asset_patterns=entry["asset_patterns"], + tier=entry.get("tier", 2), + executable_names=entry.get("executable_names", []), + pinned_version=entry.get("pinned_version"), + can_uninstall=entry.get("can_uninstall", True), + can_downgrade=entry.get("can_downgrade", True), + is_engine=entry.get("is_engine", False), + can_launch=entry.get("can_launch", False), + nexus_mod_id=entry.get("nexus_mod_id"), + nexus_game_domain=entry.get("nexus_game_domain", "site"), + nexus_file_filter=entry.get("nexus_file_filter"), + hidden=entry.get("hidden", False), + )) + except (KeyError, TypeError) as e: + logger.warning("Skipping malformed manifest entry: %s", e) + return definitions if definitions else None + + +def _load_bundled_manifest() -> Optional[List[ToolDefinition]]: + try: + with open(_BUNDLED_MANIFEST_PATH, "r", encoding="utf-8") as fh: + entries = json.load(fh) + if not isinstance(entries, list): + return None + return _parse_manifest_entries(entries) + except Exception as e: + logger.debug("Bundled manifest load failed: %s", e) + return None + + +_manifest_cache: Optional[List[ToolDefinition]] = _load_bundled_manifest() + + +def fetch_remote_manifest() -> Optional[List[ToolDefinition]]: + """Fetch the remote tool manifest. Returns parsed definitions or None on failure.""" + try: + resp = requests.get(TOOL_MANIFEST_URL, timeout=8, verify=True) + resp.raise_for_status() + entries = resp.json() + if not isinstance(entries, list): + return None + return _parse_manifest_entries(entries) + except Exception as e: + logger.debug("Tool manifest fetch failed: %s", e) + return None + +def get_effective_definitions() -> List[ToolDefinition]: + """Remote manifest definitions if fetched this session, else baked-in TOOL_DEFINITIONS.""" + source = _manifest_cache if _manifest_cache is not None else TOOL_DEFINITIONS + return [d for d in source if not d.hidden] + +def apply_remote_manifest(definitions: List[ToolDefinition]) -> None: + """Store fetched manifest as session cache and rebuild the tool map.""" + global _manifest_cache, _TOOL_MAP + _manifest_cache = definitions + _TOOL_MAP = {t.tool_id: t for t in definitions} -# --------------------------------------------------------------------------- -# Manifest helpers -# --------------------------------------------------------------------------- def _manifest_path(tool_id: str) -> Path: return TOOLS_BASE_DIR / tool_id / "manifest.json" + def _read_manifest(tool_id: str) -> dict: mp = _manifest_path(tool_id) if mp.exists(): @@ -143,34 +237,25 @@ def _write_manifest(tool_id: str, data: dict) -> None: mp.write_text(json.dumps(data, indent=2)) -# --------------------------------------------------------------------------- -# TTW bridge - reads existing config keys written by TTWInstallerHandler -# --------------------------------------------------------------------------- - def _ttw_status_from_config() -> Tuple[bool, Optional[str], Optional[Path]]: - """Return (installed, version, binary_path) by reading TTWInstallerHandler config.""" try: - from jackify.backend.handlers.config_handler import ConfigHandler - cfg = ConfigHandler() - version = cfg.get("ttw_installer_version") - install_path_str = cfg.get("ttw_installer_install_path") - if not install_path_str: - return False, None, None - install_dir = Path(install_path_str) - for exe_name in ["mpi_installer", "ttw_linux_gui"]: - exe = install_dir / exe_name - if exe.is_file(): - return True, str(version) if version else None, exe + search_dirs = [ + TOOLS_BASE_DIR / "ttw_installer", + get_jackify_data_dir() / "TTW_Linux_Installer", # legacy location + ] + for tool_dir in search_dirs: + for exe_name in ["ttw_linux_gui", "mpi_installer"]: + exe = tool_dir / exe_name + if exe.is_file(): + manifest = _read_manifest("ttw_installer") + version = manifest.get("installed_version") + return True, version, exe return False, None, None except Exception as e: - logger.debug("TTW config read failed: %s", e) + logger.debug("TTW status check failed: %s", e) return False, None, None -# --------------------------------------------------------------------------- -# GitHub release fetching -# --------------------------------------------------------------------------- - def fetch_latest_release_info(github_repo: str, pinned_version: Optional[str] = None) -> Optional[dict]: """Fetch release metadata from GitHub API. Returns parsed JSON or None on failure.""" if pinned_version: @@ -188,12 +273,27 @@ def fetch_latest_release_info(github_repo: str, pinned_version: Optional[str] = try: resp = requests.get(url, timeout=10, verify=True) resp.raise_for_status() - return resp.json() + data = resp.json() + tag = data.get("tag_name") or data.get("name", "unknown") + logger.info("Latest release for %s: %s", github_repo, tag) + return data except Exception as e: logger.debug("GitHub fetch error for %s: %s", github_repo, e) return None +def fetch_release_list(github_repo: str, max_count: int = 10) -> List[dict]: + """Return a list of release dicts (tag_name, name, published_at) from GitHub, newest first.""" + url = f"https://api.github.com/repos/{github_repo}/releases?per_page={max_count}" + try: + resp = requests.get(url, timeout=10, verify=True) + resp.raise_for_status() + return resp.json() + except Exception as e: + logger.debug("Release list fetch failed for %s: %s", github_repo, e) + return [] + + def _find_asset(release_data: dict, asset_patterns: List[str]) -> Optional[dict]: assets = release_data.get("assets", []) for pattern in asset_patterns: @@ -203,48 +303,149 @@ def _find_asset(release_data: dict, asset_patterns: List[str]) -> Optional[dict] return None -# --------------------------------------------------------------------------- -# Core install logic (shared across all non-TTW tools) -# --------------------------------------------------------------------------- +def _find_sums_asset(release_data: dict, asset_name: str) -> Optional[dict]: + """Find a .SHA256SUMS release asset that covers the given filename.""" + assets = release_data.get("assets", []) + stem = asset_name.rsplit(".", 2)[0] if asset_name.endswith(".tar.gz") else Path(asset_name).stem + for asset in assets: + name = asset.get("name", "") + if name.endswith(".SHA256SUMS") and stem in name: + return asset + for asset in assets: + if asset.get("name", "").endswith(".SHA256SUMS"): + return asset + return None -def _download_and_extract(tool_id: str, asset: dict, target_dir: Path) -> Tuple[bool, str]: - """Download a release asset and extract it into target_dir.""" + +def _verify_sha256_sums(sums_path: Path, target_path: Path) -> Tuple[bool, str]: + """Parse a SHA256SUMS file and verify target_path. Format: 'hash filename'.""" + import hashlib + try: + expected_hash = None + for line in sums_path.read_text().strip().splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[1] == target_path.name: + expected_hash = parts[0].lower() + break + if not expected_hash: + return False, f"No entry for {target_path.name} in SHA256SUMS file" + sha256 = hashlib.sha256() + with open(target_path, "rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + sha256.update(chunk) + actual = sha256.hexdigest().lower() + if actual != expected_hash: + return False, f"SHA256 mismatch for {target_path.name}" + return True, "" + except Exception as e: + return False, f"SHA256 verification error: {e}" + + +def _extract_archive(file_path: Path, target_dir: Path) -> Tuple[bool, str]: + """Extract an archive or chmod an AppImage in place. Removes the archive on success.""" + name_lower = file_path.name.lower() + is_archive = False + try: + if name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"): + is_archive = True + with tarfile.open(file_path, "r:gz") as tf: + tf.extractall(path=target_dir) + elif name_lower.endswith(".zip"): + is_archive = True + with zipfile.ZipFile(file_path, "r") as zf: + zf.extractall(path=target_dir) + elif name_lower.endswith(".appimage"): + file_path.chmod(0o755) + else: + return False, f"Unsupported format: {file_path.name}" + finally: + if is_archive: + try: + file_path.unlink(missing_ok=True) + except Exception: + pass + if is_archive: + _chmod_elf_binaries(target_dir) + return True, "" + + +def _chmod_elf_binaries(directory: Path) -> None: + """Set executable bit on any ELF binaries found directly in directory.""" + ELF_MAGIC = b'\x7fELF' + for f in directory.iterdir(): + if not f.is_file(): + continue + try: + with open(f, 'rb') as fh: + magic = fh.read(4) + if magic == ELF_MAGIC: + f.chmod(f.stat().st_mode | 0o111) + except Exception: + pass + + +def _download_and_extract( + tool_id: str, + asset: dict, + target_dir: Path, + sums_asset: Optional[dict] = None, +) -> Tuple[bool, str]: + """Download a GitHub release asset, optionally verify SHA256, then extract.""" from jackify.backend.handlers.filesystem_handler import FileSystemHandler fs = FileSystemHandler() - asset_name = asset.get("name", "") download_url = asset.get("browser_download_url", "") if not download_url: return False, "Asset has no download URL" - temp_path = target_dir / asset_name logger.info("Downloading %s", asset_name) if not fs.download_file(download_url, temp_path, overwrite=True, quiet=True): return False, f"Download failed: {asset_name}" - - try: - name_lower = asset_name.lower() - is_archive = False - if name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"): - is_archive = True - with tarfile.open(temp_path, "r:gz") as tf: - tf.extractall(path=target_dir) - elif name_lower.endswith(".zip"): - is_archive = True - with zipfile.ZipFile(temp_path, "r") as zf: - zf.extractall(path=target_dir) - elif name_lower.endswith(".appimage"): - temp_path.chmod(0o755) - else: - return False, f"Unsupported archive format: {asset_name}" - finally: - if is_archive: + if sums_asset: + sums_url = sums_asset.get("browser_download_url", "") + sums_path = target_dir / sums_asset.get("name", "SHA256SUMS") + if sums_url and fs.download_file(sums_url, sums_path, overwrite=True, quiet=True): + ok, err = _verify_sha256_sums(sums_path, temp_path) try: - temp_path.unlink(missing_ok=True) + sums_path.unlink(missing_ok=True) except Exception: pass + if not ok: + try: + temp_path.unlink(missing_ok=True) + except Exception: + pass + return False, err + logger.info("SHA256 verified for %s", asset_name) + else: + logger.warning("SHA256SUMS download failed for %s, skipping verification", asset_name) + return _extract_archive(temp_path, target_dir) - return True, "" + +def _try_nexus_download(defn: ToolDefinition, target_dir: Path) -> Tuple[bool, Optional[Path], str]: + """Attempt Nexus CDN download for premium users. Returns (success, file_path, message).""" + if not defn.nexus_mod_id: + return False, None, "No Nexus mod configured" + try: + from jackify.backend.services.nexus_auth_service import NexusAuthService + from jackify.backend.services.nexus_premium_service import NexusPremiumService + from jackify.backend.services.nexus_download_service import NexusDownloadService + auth = NexusAuthService() + token = auth.get_auth_token() + if not token: + return False, None, "No Nexus auth token" + is_oauth = auth.get_auth_method() == "oauth" + is_premium, _ = NexusPremiumService().check_premium_status(token, is_oauth=is_oauth) + if not is_premium: + return False, None, "Not Nexus Premium" + ok, path, msg = NexusDownloadService(token).download_latest_file( + defn.nexus_game_domain, defn.nexus_mod_id, target_dir, + file_name_filter=defn.nexus_file_filter, + ) + return ok, path, msg + except Exception as e: + logger.debug("Nexus download attempt failed for %s: %s", defn.tool_id, e) + return False, None, str(e) def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Path]: @@ -255,17 +456,12 @@ def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Pat for found in search_dir.rglob(exe_name): if found.is_file(): return found - # AppImage pattern for found in search_dir.rglob(f"{exe_name}*.AppImage"): if found.is_file(): return found return None -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - class ToolRegistry: """Read/write interface to the managed tool store.""" @@ -276,40 +472,58 @@ class ToolRegistry: return self._build_status(defn) def get_all_statuses(self) -> List[ToolStatus]: - return [self._build_status(d) for d in TOOL_DEFINITIONS] + return [self._build_status(d) for d in get_effective_definitions()] def check_latest_version(self, tool_id: str) -> Optional[str]: - """Fetch latest tag from GitHub. Returns tag string or None.""" defn = _TOOL_MAP.get(tool_id) if defn is None: return None - data = fetch_latest_release_info(defn.github_repo, defn.pinned_version) + if defn.pinned_version: + return defn.pinned_version + if defn.include_prereleases: + releases = fetch_release_list(defn.github_repo, max_count=5) + if releases: + data = releases[0] + return data.get("tag_name") or data.get("name") + return None + data = fetch_latest_release_info(defn.github_repo) if data: return data.get("tag_name") or data.get("name") return None - def install(self, tool_id: str) -> Tuple[bool, str]: + def install(self, tool_id: str, version: Optional[str] = None) -> Tuple[bool, str]: defn = _TOOL_MAP.get(tool_id) if defn is None: return False, f"Unknown tool: {tool_id}" - + if defn.hidden: + return False, f"{defn.display_name} is not available for install" if tool_id == "ttw_installer": return self._install_ttw() install_dir = TOOLS_BASE_DIR / tool_id install_dir.mkdir(parents=True, exist_ok=True) - data = fetch_latest_release_info(defn.github_repo, defn.pinned_version) - if not data: - return False, f"Could not fetch release info for {defn.display_name}" + pin = version or defn.pinned_version + nexus_ok, nexus_path, _ = _try_nexus_download(defn, install_dir) if not version else (False, None, "") + if nexus_ok and nexus_path: + ok, err = _extract_archive(nexus_path, install_dir) + tag = pin or "nexus" + else: + if defn.include_prereleases and not pin: + releases = fetch_release_list(defn.github_repo, max_count=5) + data = releases[0] if releases else None + else: + data = fetch_latest_release_info(defn.github_repo, pin) + if not data: + return False, f"Could not fetch release info for {defn.display_name}" + asset = _find_asset(data, defn.asset_patterns) + if not asset: + all_names = [a.get("name", "") for a in data.get("assets", [])] + return False, f"No matching asset found. Available: {', '.join(all_names)}" + tag = data.get("tag_name") or data.get("name", "unknown") + sums_asset = _find_sums_asset(data, asset.get("name", "")) + ok, err = _download_and_extract(tool_id, asset, install_dir, sums_asset=sums_asset) - asset = _find_asset(data, defn.asset_patterns) - if not asset: - all_names = [a.get("name", "") for a in data.get("assets", [])] - return False, f"No matching asset found. Available: {', '.join(all_names)}" - - tag = data.get("tag_name") or data.get("name", "unknown") - ok, err = _download_and_extract(tool_id, asset, install_dir) if not ok: return False, err @@ -332,7 +546,6 @@ class ToolRegistry: return True, f"{defn.display_name} {tag} installed" def update(self, tool_id: str) -> Tuple[bool, str]: - """Update to latest release. Saves current as previous for downgrade.""" defn = _TOOL_MAP.get(tool_id) if defn is None: return False, f"Unknown tool: {tool_id}" @@ -447,7 +660,6 @@ class ToolRegistry: return True, f"{defn.display_name} uninstalled" def get_binary_path(self, tool_id: str) -> Optional[Path]: - """Return the installed binary path for a Tier 1 tool, or None.""" if tool_id == "ttw_installer": _, _, binary = _ttw_status_from_config() return binary @@ -478,6 +690,21 @@ class ToolRegistry: binary_path_str = manifest.get("binary_path") binary_path = Path(binary_path_str) if binary_path_str else None installed = installed_version is not None and (binary_path is None or binary_path.is_file()) + + if not installed and defn.tool_id == "jackify-engine": + try: + from jackify.backend.core.modlist_operations import get_jackify_engine_path + bundled = Path(get_jackify_engine_path()) + if bundled.is_file(): + installed = True + binary_path = bundled + if not installed_version: + version_file = bundled.parent / "version.txt" + if version_file.is_file(): + installed_version = version_file.read_text().strip() or None + except Exception: + pass + return ToolStatus( definition=defn, installed=installed, @@ -487,7 +714,7 @@ class ToolRegistry: ) def _install_ttw(self) -> Tuple[bool, str]: - """Delegate TTW install to the existing handler.""" + """Delegate TTW install to the existing handler, installing into the tools directory.""" try: from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler from jackify.backend.handlers.filesystem_handler import FileSystemHandler @@ -498,6 +725,16 @@ class ToolRegistry: steamdeck=False, verbose=False, filesystem_handler=fs, config_handler=cfg, ) - return handler.install_ttw_installer() + install_dir = TOOLS_BASE_DIR / "ttw_installer" + install_dir.mkdir(parents=True, exist_ok=True) + ok, msg = handler.install_ttw_installer(install_dir=install_dir) + if ok: + version = cfg.get("ttw_installer_version") or "unknown" + exe_path = _find_executable(_TOOL_MAP["ttw_installer"], install_dir) + _write_manifest("ttw_installer", { + "installed_version": version, + "binary_path": str(exe_path) if exe_path else None, + }) + return ok, msg except Exception as e: return False, f"TTW install failed: {e}" diff --git a/jackify/backend/services/tools_manifest.json b/jackify/backend/services/tools_manifest.json new file mode 100644 index 0000000..030695b --- /dev/null +++ b/jackify/backend/services/tools_manifest.json @@ -0,0 +1,55 @@ +[ + { + "tool_id": "jackify-engine", + "display_name": "jackify-engine", + "description": "Native Wabbajack-matched file handler. The proven, stable engine for modlist installs.", + "github_repo": "Omni-guides/jackify-engine", + "asset_patterns": ["jackify-engine.*linux.*x64.*\\.tar\\.gz", "jackify-engine.*\\.tar\\.gz", "jackify-engine.*\\.zip"], + "executable_names": ["jackify-engine"], + "tier": 1, + "can_uninstall": false, + "is_engine": true, + "can_launch": false + }, + { + "tool_id": "clf3", + "display_name": "CLF3", + "description": "Rust-based Wabbajack file handler. Faster installs, slightly slower modlist updates than jackify-engine.", + "github_repo": "SulfurNitride/CLF3", + "asset_patterns": ["clf3.*linux.*x86_64", "clf3.*\\.tar\\.gz", "clf3.*\\.zip"], + "executable_names": ["clf3"], + "tier": 1, + "can_uninstall": true, + "is_engine": true, + "can_launch": false + }, + { + "tool_id": "ttw_installer", + "display_name": "TTW Linux Installer", + "description": "Automates Tale of Two Wastelands installation on Linux. Required for the TTW workflow.", + "github_repo": "SulfurNitride/TTW_Linux_Installer", + "asset_patterns": ["universal-mpi-installer.*\\.(zip|tar\\.gz)"], + "executable_names": ["mpi_installer", "ttw_linux_gui"], + "tier": 1, + "can_uninstall": true, + "can_launch": true, + "pinned_version": "0.0.7", + "nexus_mod_id": 1657, + "nexus_game_domain": "site", + "nexus_file_filter": "mpi" + }, + { + "tool_id": "radium", + "display_name": "Radium Textures", + "description": "Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation. Run directly against mod files.", + "github_repo": "SulfurNitride/Radium-Textures", + "asset_patterns": ["radium.*linux.*x86_64", "radium.*\\.tar\\.gz", "radium.*\\.zip"], + "executable_names": ["radium", "radium-textures"], + "tier": 2, + "can_uninstall": true, + "can_launch": true, + "nexus_mod_id": 1660, + "nexus_game_domain": "site", + "nexus_file_filter": "linux" + } +] diff --git a/jackify/backend/services/ttw_installer_service.py b/jackify/backend/services/ttw_installer_service.py index 5f72af2..c305931 100644 --- a/jackify/backend/services/ttw_installer_service.py +++ b/jackify/backend/services/ttw_installer_service.py @@ -24,11 +24,8 @@ def _build_handler() -> TTWInstallerHandler: def get_ttw_installer_path() -> Optional[Path]: """Return the resolved TTW_Linux_Installer executable path, if available.""" - handler = _build_handler() - path = handler.ttw_installer_executable_path - if path and path.exists(): - return path - return None + from jackify.backend.services.tool_registry import ToolRegistry + return ToolRegistry().get_binary_path("ttw_installer") def ensure_ttw_installer_available( @@ -47,13 +44,17 @@ def ensure_ttw_installer_available( if progress_callback: progress_callback("TTW_Linux_Installer not found, installing...") + from jackify.backend.services.tool_registry import TOOLS_BASE_DIR + install_dir = TOOLS_BASE_DIR / "ttw_installer" + install_dir.mkdir(parents=True, exist_ok=True) + handler = _build_handler() - success, message = handler.install_ttw_installer() + success, message = handler.install_ttw_installer(install_dir=install_dir) if not success: logger.error("Failed to install TTW_Linux_Installer: %s", message) return None, message - path = handler.ttw_installer_executable_path + path = get_ttw_installer_path() if path and path.exists(): if progress_callback: progress_callback("TTW_Linux_Installer installed successfully") diff --git a/jackify/backend/utils/clf3_postinstall.py b/jackify/backend/utils/clf3_postinstall.py new file mode 100644 index 0000000..517a422 --- /dev/null +++ b/jackify/backend/utils/clf3_postinstall.py @@ -0,0 +1,49 @@ +""" +Post-install fixups applied after a CLF3 install completes. +""" +import logging +import re +from pathlib import Path + +logger = logging.getLogger(__name__) + +_SDCARD_RE = re.compile(r'^/run/media/') + + +def _linux_to_wine_path(path_str: str) -> str: + """Convert an absolute Linux path to a Wine-format path (Z: or D: for SD card).""" + if _SDCARD_RE.match(path_str): + from jackify.backend.handlers.wine_utils import WineUtils + stripped = WineUtils._strip_sdcard_path(path_str).lstrip('/') + return "D:\\" + stripped.replace('/', '\\') + return "Z:\\" + path_str.lstrip('/').replace('/', '\\') + + +def inject_mo2_download_dir(install_dir: str, downloads_dir: str) -> None: + """Ensure ModOrganizer.ini contains download_directory pointing at downloads_dir. + + Some modlists ship a bundled ModOrganizer.ini that omits download_directory. + MO2 then defaults to its own internal path, causing it to report all downloads + as missing on first launch. This injects the correct path if absent. + """ + ini_path = Path(install_dir) / "ModOrganizer.ini" + if not ini_path.exists(): + return + + content = ini_path.read_text(encoding="utf-8", errors="replace") + + if "download_directory=" in content.lower(): + return + + wine_path = _linux_to_wine_path(str(downloads_dir)) + entry = f"download_directory={wine_path}" + + lines = content.splitlines(keepends=True) + for i, line in enumerate(lines): + if line.strip().lower() == "[general]": + lines.insert(i + 1, entry + "\n") + ini_path.write_text("".join(lines), encoding="utf-8") + logger.info(f"Injected download_directory into {ini_path}: {wine_path}") + return + + logger.debug(f"ModOrganizer.ini has no [General] section, skipping injection: {ini_path}") diff --git a/jackify/backend/utils/engine_error_parser.py b/jackify/backend/utils/engine_error_parser.py index 67e1b72..973dee4 100644 --- a/jackify/backend/utils/engine_error_parser.py +++ b/jackify/backend/utils/engine_error_parser.py @@ -7,6 +7,42 @@ from jackify.shared.errors import ( game_not_found_for_modlist, ) +_HTTP_TIMEOUT_RE = re.compile( + r"httpclient\.timeout|request was canceled.*timeout|timed?\s*out.*elaps", + re.IGNORECASE, +) + +_NEXUS_DOWNLOAD_RE = re.compile( + r"Game:\s*([A-Za-z0-9]+),\s*ModID:\s*(\d+)", + re.IGNORECASE, +) + +_NEXUS_GAME_SLUGS = { + "skyrimspecialedition": "skyrimspecialedition", + "skyrim": "skyrim", + "skyrimvr": "skyrimvr", + "fallout4": "fallout4", + "fallout4vr": "fallout4vr", + "falloutneeds": "newvegas", + "falloutnewvegas": "newvegas", + "fallout3": "fallout3", + "oblivion": "oblivion", + "oblivionremastered": "oblivionremastered", + "starfield": "starfield", + "baldursgate3": "baldursgate3", +} + + +def nexus_url_from_error_line(line: str) -> Optional[str]: + """Extract a Nexus mod page URL from an engine download-failure line, or return None.""" + m = _NEXUS_DOWNLOAD_RE.search(line) + if not m: + return None + game_raw = m.group(1).lower() + mod_id = m.group(2) + slug = _NEXUS_GAME_SLUGS.get(game_raw, game_raw) + return f"https://www.nexusmods.com/{slug}/mods/{mod_id}" + def _ctx_detail(ctx: dict) -> Optional[str]: if not ctx: @@ -14,9 +50,28 @@ def _ctx_detail(ctx: dict) -> Optional[str]: return format_technical_context(context=ctx) +def _download_timeout_error(detail: str, ctx: dict) -> InstallError: + return InstallError( + "Download Timed Out", + "A download request timed out before completing.", + suggestion="Retry the install - Wabbajack will resume from where it stopped.", + solutions=[ + "Re-run the install - Wabbajack resumes and will reattempt timed-out downloads.", + "Check your internet connection is stable.", + "Disable VPN or proxy if active.", + "Check if Nexus Mods is reachable at nexusmods.com.", + "If timeouts persist, try installing during off-peak hours or from a faster connection.", + ], + technical=_ctx_detail(ctx) or detail, + ) + + def _engine_error(msg: str, ctx: dict) -> InstallError: """Map generic engine_error payloads to user-visible, actionable InstallError variants.""" text = (msg or "").strip() + if _HTTP_TIMEOUT_RE.search(text): + return _download_timeout_error(text, ctx) + match = re.search(r"can't find game\s+([A-Za-z0-9_:-]+)", text, flags=re.IGNORECASE) if match: game_name = match.group(1) @@ -126,12 +181,19 @@ _TYPE_MAP = { ), } + +def _exit_code_6_error(detail: str, context: dict) -> JackifyError: + if detail and _HTTP_TIMEOUT_RE.search(detail): + return _download_timeout_error(detail, context) + return wabbajack_install_failed(format_technical_context(detail=detail, context=context) or detail) + + _EXIT_CODE_MAP = { 2: lambda d, c: _TYPE_MAP["auth_failed"](d, c or {}), 3: lambda d, c: _TYPE_MAP["network_error"](d, c or {}), 4: lambda d, c: _TYPE_MAP["disk_full"](d, c or {}), 5: lambda d, c: _TYPE_MAP["validation_failed"](d, c or {}), - 6: lambda d, c: wabbajack_install_failed(format_technical_context(detail=d, context=c) or d), + 6: _exit_code_6_error, } diff --git a/jackify/engine/AES-CTR-Netstandard.dll b/jackify/engine/AES-CTR-Netstandard.dll deleted file mode 100755 index 5392442..0000000 Binary files a/jackify/engine/AES-CTR-Netstandard.dll and /dev/null differ diff --git a/jackify/engine/BCnEncoder.NET.ImageSharp.dll b/jackify/engine/BCnEncoder.NET.ImageSharp.dll deleted file mode 100755 index 61b6253..0000000 Binary files a/jackify/engine/BCnEncoder.NET.ImageSharp.dll and /dev/null differ diff --git a/jackify/engine/BCnEncoder.dll b/jackify/engine/BCnEncoder.dll deleted file mode 100755 index a6fa052..0000000 Binary files a/jackify/engine/BCnEncoder.dll and /dev/null differ diff --git a/jackify/engine/Crc32.NET.dll b/jackify/engine/Crc32.NET.dll deleted file mode 100644 index 6d29e9f..0000000 Binary files a/jackify/engine/Crc32.NET.dll and /dev/null differ diff --git a/jackify/engine/DeviceId.dll b/jackify/engine/DeviceId.dll deleted file mode 100755 index 7b34922..0000000 Binary files a/jackify/engine/DeviceId.dll and /dev/null differ diff --git a/jackify/engine/Extractors/linux-x64/7zz b/jackify/engine/Extractors/linux-x64/7zz deleted file mode 100755 index 9d7c38f..0000000 Binary files a/jackify/engine/Extractors/linux-x64/7zz and /dev/null differ diff --git a/jackify/engine/Extractors/linux-x64/innoextract b/jackify/engine/Extractors/linux-x64/innoextract deleted file mode 100755 index a8443f0..0000000 Binary files a/jackify/engine/Extractors/linux-x64/innoextract and /dev/null differ diff --git a/jackify/engine/Extractors/mac/7zz b/jackify/engine/Extractors/mac/7zz deleted file mode 100644 index e84f105..0000000 Binary files a/jackify/engine/Extractors/mac/7zz and /dev/null differ diff --git a/jackify/engine/Extractors/windows-x64/7z.dll b/jackify/engine/Extractors/windows-x64/7z.dll deleted file mode 100644 index 1533be1..0000000 Binary files a/jackify/engine/Extractors/windows-x64/7z.dll and /dev/null differ diff --git a/jackify/engine/Extractors/windows-x64/7z.exe b/jackify/engine/Extractors/windows-x64/7z.exe deleted file mode 100644 index c48554a..0000000 Binary files a/jackify/engine/Extractors/windows-x64/7z.exe and /dev/null differ diff --git a/jackify/engine/Extractors/windows-x64/innoextract.exe b/jackify/engine/Extractors/windows-x64/innoextract.exe deleted file mode 100644 index 22c33dc..0000000 Binary files a/jackify/engine/Extractors/windows-x64/innoextract.exe and /dev/null differ diff --git a/jackify/engine/F23.StringSimilarity.dll b/jackify/engine/F23.StringSimilarity.dll deleted file mode 100644 index ca41b8c..0000000 Binary files a/jackify/engine/F23.StringSimilarity.dll and /dev/null differ diff --git a/jackify/engine/FluentFTP.dll b/jackify/engine/FluentFTP.dll deleted file mode 100644 index 478da7a..0000000 Binary files a/jackify/engine/FluentFTP.dll and /dev/null differ diff --git a/jackify/engine/FluentResults.dll b/jackify/engine/FluentResults.dll deleted file mode 100755 index ad348bb..0000000 Binary files a/jackify/engine/FluentResults.dll and /dev/null differ diff --git a/jackify/engine/GameFinder.Common.dll b/jackify/engine/GameFinder.Common.dll deleted file mode 100755 index cd2c2dd..0000000 Binary files a/jackify/engine/GameFinder.Common.dll and /dev/null differ diff --git a/jackify/engine/GameFinder.RegistryUtils.dll b/jackify/engine/GameFinder.RegistryUtils.dll deleted file mode 100755 index a28a11d..0000000 Binary files a/jackify/engine/GameFinder.RegistryUtils.dll and /dev/null differ diff --git a/jackify/engine/GameFinder.StoreHandlers.EADesktop.dll b/jackify/engine/GameFinder.StoreHandlers.EADesktop.dll deleted file mode 100755 index 5ae1702..0000000 Binary files a/jackify/engine/GameFinder.StoreHandlers.EADesktop.dll and /dev/null differ diff --git a/jackify/engine/GameFinder.StoreHandlers.EGS.dll b/jackify/engine/GameFinder.StoreHandlers.EGS.dll deleted file mode 100755 index f3f5b56..0000000 Binary files a/jackify/engine/GameFinder.StoreHandlers.EGS.dll and /dev/null differ diff --git a/jackify/engine/GameFinder.StoreHandlers.GOG.dll b/jackify/engine/GameFinder.StoreHandlers.GOG.dll deleted file mode 100755 index a771931..0000000 Binary files a/jackify/engine/GameFinder.StoreHandlers.GOG.dll and /dev/null differ diff --git a/jackify/engine/GameFinder.StoreHandlers.Origin.dll b/jackify/engine/GameFinder.StoreHandlers.Origin.dll deleted file mode 100755 index c1705a1..0000000 Binary files a/jackify/engine/GameFinder.StoreHandlers.Origin.dll and /dev/null differ diff --git a/jackify/engine/GameFinder.StoreHandlers.Steam.dll b/jackify/engine/GameFinder.StoreHandlers.Steam.dll deleted file mode 100755 index 2043e60..0000000 Binary files a/jackify/engine/GameFinder.StoreHandlers.Steam.dll and /dev/null differ diff --git a/jackify/engine/GameFinder.Wine.dll b/jackify/engine/GameFinder.Wine.dll deleted file mode 100755 index db67e4b..0000000 Binary files a/jackify/engine/GameFinder.Wine.dll and /dev/null differ diff --git a/jackify/engine/HtmlAgilityPack.dll b/jackify/engine/HtmlAgilityPack.dll deleted file mode 100755 index c70703e..0000000 Binary files a/jackify/engine/HtmlAgilityPack.dll and /dev/null differ diff --git a/jackify/engine/ICSharpCode.SharpZipLib.dll b/jackify/engine/ICSharpCode.SharpZipLib.dll deleted file mode 100644 index 52fd182..0000000 Binary files a/jackify/engine/ICSharpCode.SharpZipLib.dll and /dev/null differ diff --git a/jackify/engine/INIFileParser.dll b/jackify/engine/INIFileParser.dll deleted file mode 100644 index a4cc544..0000000 Binary files a/jackify/engine/INIFileParser.dll and /dev/null differ diff --git a/jackify/engine/K4os.Compression.LZ4.Streams.dll b/jackify/engine/K4os.Compression.LZ4.Streams.dll deleted file mode 100644 index db1a982..0000000 Binary files a/jackify/engine/K4os.Compression.LZ4.Streams.dll and /dev/null differ diff --git a/jackify/engine/K4os.Compression.LZ4.dll b/jackify/engine/K4os.Compression.LZ4.dll deleted file mode 100644 index d869427..0000000 Binary files a/jackify/engine/K4os.Compression.LZ4.dll and /dev/null differ diff --git a/jackify/engine/K4os.Hash.xxHash.dll b/jackify/engine/K4os.Hash.xxHash.dll deleted file mode 100644 index 7217ea7..0000000 Binary files a/jackify/engine/K4os.Hash.xxHash.dll and /dev/null differ diff --git a/jackify/engine/Markdig.dll b/jackify/engine/Markdig.dll deleted file mode 100644 index 7a29eec..0000000 Binary files a/jackify/engine/Markdig.dll and /dev/null differ diff --git a/jackify/engine/MegaApiClient.dll b/jackify/engine/MegaApiClient.dll deleted file mode 100644 index a117a37..0000000 Binary files a/jackify/engine/MegaApiClient.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.AspNetCore.Http.Abstractions.dll b/jackify/engine/Microsoft.AspNetCore.Http.Abstractions.dll deleted file mode 100755 index 6c03778..0000000 Binary files a/jackify/engine/Microsoft.AspNetCore.Http.Abstractions.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.AspNetCore.Http.Extensions.dll b/jackify/engine/Microsoft.AspNetCore.Http.Extensions.dll deleted file mode 100755 index 6fdd574..0000000 Binary files a/jackify/engine/Microsoft.AspNetCore.Http.Extensions.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.AspNetCore.Http.Features.dll b/jackify/engine/Microsoft.AspNetCore.Http.Features.dll deleted file mode 100755 index be4d1cd..0000000 Binary files a/jackify/engine/Microsoft.AspNetCore.Http.Features.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.CSharp.dll b/jackify/engine/Microsoft.CSharp.dll deleted file mode 100755 index e2bace8..0000000 Binary files a/jackify/engine/Microsoft.CSharp.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.Abstractions.dll b/jackify/engine/Microsoft.Extensions.Configuration.Abstractions.dll deleted file mode 100644 index 805cec2..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Configuration.Abstractions.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.Binder.dll b/jackify/engine/Microsoft.Extensions.Configuration.Binder.dll deleted file mode 100644 index a856e4a..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Configuration.Binder.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.CommandLine.dll b/jackify/engine/Microsoft.Extensions.Configuration.CommandLine.dll deleted file mode 100644 index b41cdec..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Configuration.CommandLine.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.EnvironmentVariables.dll b/jackify/engine/Microsoft.Extensions.Configuration.EnvironmentVariables.dll deleted file mode 100644 index a45424d..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Configuration.EnvironmentVariables.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.FileExtensions.dll b/jackify/engine/Microsoft.Extensions.Configuration.FileExtensions.dll deleted file mode 100644 index 07d1727..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Configuration.FileExtensions.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.Json.dll b/jackify/engine/Microsoft.Extensions.Configuration.Json.dll deleted file mode 100644 index 29ce3d6..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Configuration.Json.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.UserSecrets.dll b/jackify/engine/Microsoft.Extensions.Configuration.UserSecrets.dll deleted file mode 100644 index ef9753d..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Configuration.UserSecrets.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.dll b/jackify/engine/Microsoft.Extensions.Configuration.dll deleted file mode 100644 index e2968a8..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Configuration.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.DependencyInjection.Abstractions.dll b/jackify/engine/Microsoft.Extensions.DependencyInjection.Abstractions.dll deleted file mode 100644 index a9427e6..0000000 Binary files a/jackify/engine/Microsoft.Extensions.DependencyInjection.Abstractions.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.DependencyInjection.dll b/jackify/engine/Microsoft.Extensions.DependencyInjection.dll deleted file mode 100644 index 45c1b54..0000000 Binary files a/jackify/engine/Microsoft.Extensions.DependencyInjection.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Diagnostics.Abstractions.dll b/jackify/engine/Microsoft.Extensions.Diagnostics.Abstractions.dll deleted file mode 100644 index 41a0e3c..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Diagnostics.Abstractions.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Diagnostics.dll b/jackify/engine/Microsoft.Extensions.Diagnostics.dll deleted file mode 100644 index 790cbd4..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Diagnostics.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.FileProviders.Abstractions.dll b/jackify/engine/Microsoft.Extensions.FileProviders.Abstractions.dll deleted file mode 100644 index c3648d6..0000000 Binary files a/jackify/engine/Microsoft.Extensions.FileProviders.Abstractions.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.FileProviders.Physical.dll b/jackify/engine/Microsoft.Extensions.FileProviders.Physical.dll deleted file mode 100644 index 4a4c320..0000000 Binary files a/jackify/engine/Microsoft.Extensions.FileProviders.Physical.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.FileSystemGlobbing.dll b/jackify/engine/Microsoft.Extensions.FileSystemGlobbing.dll deleted file mode 100644 index eb00503..0000000 Binary files a/jackify/engine/Microsoft.Extensions.FileSystemGlobbing.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Hosting.Abstractions.dll b/jackify/engine/Microsoft.Extensions.Hosting.Abstractions.dll deleted file mode 100644 index 1fa0eaf..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Hosting.Abstractions.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Hosting.dll b/jackify/engine/Microsoft.Extensions.Hosting.dll deleted file mode 100644 index a5e4e5b..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Hosting.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Http.dll b/jackify/engine/Microsoft.Extensions.Http.dll deleted file mode 100644 index c98cf7d..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Http.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.Abstractions.dll b/jackify/engine/Microsoft.Extensions.Logging.Abstractions.dll deleted file mode 100644 index 90d2231..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Logging.Abstractions.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.Configuration.dll b/jackify/engine/Microsoft.Extensions.Logging.Configuration.dll deleted file mode 100644 index 88e315f..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Logging.Configuration.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.Console.dll b/jackify/engine/Microsoft.Extensions.Logging.Console.dll deleted file mode 100644 index 805570b..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Logging.Console.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.Debug.dll b/jackify/engine/Microsoft.Extensions.Logging.Debug.dll deleted file mode 100644 index 1fcbf07..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Logging.Debug.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.EventLog.dll b/jackify/engine/Microsoft.Extensions.Logging.EventLog.dll deleted file mode 100644 index ed07e3b..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Logging.EventLog.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.EventSource.dll b/jackify/engine/Microsoft.Extensions.Logging.EventSource.dll deleted file mode 100644 index b67516c..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Logging.EventSource.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.dll b/jackify/engine/Microsoft.Extensions.Logging.dll deleted file mode 100644 index 7559ed4..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Logging.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Options.ConfigurationExtensions.dll b/jackify/engine/Microsoft.Extensions.Options.ConfigurationExtensions.dll deleted file mode 100644 index 8e02081..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Options.ConfigurationExtensions.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Options.dll b/jackify/engine/Microsoft.Extensions.Options.dll deleted file mode 100644 index a9358be..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Options.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Extensions.Primitives.dll b/jackify/engine/Microsoft.Extensions.Primitives.dll deleted file mode 100644 index d6b5bfc..0000000 Binary files a/jackify/engine/Microsoft.Extensions.Primitives.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Net.Http.Headers.dll b/jackify/engine/Microsoft.Net.Http.Headers.dll deleted file mode 100755 index 3e76103..0000000 Binary files a/jackify/engine/Microsoft.Net.Http.Headers.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Toolkit.HighPerformance.dll b/jackify/engine/Microsoft.Toolkit.HighPerformance.dll deleted file mode 100755 index 376a1bc..0000000 Binary files a/jackify/engine/Microsoft.Toolkit.HighPerformance.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.VisualBasic.Core.dll b/jackify/engine/Microsoft.VisualBasic.Core.dll deleted file mode 100755 index ec23bb5..0000000 Binary files a/jackify/engine/Microsoft.VisualBasic.Core.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.VisualBasic.dll b/jackify/engine/Microsoft.VisualBasic.dll deleted file mode 100755 index c1600f4..0000000 Binary files a/jackify/engine/Microsoft.VisualBasic.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Win32.Primitives.dll b/jackify/engine/Microsoft.Win32.Primitives.dll deleted file mode 100755 index 59fc790..0000000 Binary files a/jackify/engine/Microsoft.Win32.Primitives.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Win32.Registry.dll b/jackify/engine/Microsoft.Win32.Registry.dll deleted file mode 100755 index 816eb4d..0000000 Binary files a/jackify/engine/Microsoft.Win32.Registry.dll and /dev/null differ diff --git a/jackify/engine/Microsoft.Win32.SystemEvents.dll b/jackify/engine/Microsoft.Win32.SystemEvents.dll deleted file mode 100755 index 9cb672f..0000000 Binary files a/jackify/engine/Microsoft.Win32.SystemEvents.dll and /dev/null differ diff --git a/jackify/engine/NLog.Extensions.Logging.dll b/jackify/engine/NLog.Extensions.Logging.dll deleted file mode 100644 index 32c7828..0000000 Binary files a/jackify/engine/NLog.Extensions.Logging.dll and /dev/null differ diff --git a/jackify/engine/NLog.dll b/jackify/engine/NLog.dll deleted file mode 100644 index 2a6a2fd..0000000 Binary files a/jackify/engine/NLog.dll and /dev/null differ diff --git a/jackify/engine/Nettle.dll b/jackify/engine/Nettle.dll deleted file mode 100644 index c74ff96..0000000 Binary files a/jackify/engine/Nettle.dll and /dev/null differ diff --git a/jackify/engine/Newtonsoft.Json.dll b/jackify/engine/Newtonsoft.Json.dll deleted file mode 100644 index eebef3c..0000000 Binary files a/jackify/engine/Newtonsoft.Json.dll and /dev/null differ diff --git a/jackify/engine/NexusMods.Paths.dll b/jackify/engine/NexusMods.Paths.dll deleted file mode 100755 index 151a376..0000000 Binary files a/jackify/engine/NexusMods.Paths.dll and /dev/null differ diff --git a/jackify/engine/OMODFramework.dll b/jackify/engine/OMODFramework.dll deleted file mode 100644 index 6ef9f14..0000000 Binary files a/jackify/engine/OMODFramework.dll and /dev/null differ diff --git a/jackify/engine/Octodiff.dll b/jackify/engine/Octodiff.dll deleted file mode 100644 index 0ee51df..0000000 Binary files a/jackify/engine/Octodiff.dll and /dev/null differ diff --git a/jackify/engine/Octokit.dll b/jackify/engine/Octokit.dll deleted file mode 100644 index 104a709..0000000 Binary files a/jackify/engine/Octokit.dll and /dev/null differ diff --git a/jackify/engine/OneOf.dll b/jackify/engine/OneOf.dll deleted file mode 100755 index 8267305..0000000 Binary files a/jackify/engine/OneOf.dll and /dev/null differ diff --git a/jackify/engine/Reloaded.Memory.dll b/jackify/engine/Reloaded.Memory.dll deleted file mode 100755 index 3fb16d7..0000000 Binary files a/jackify/engine/Reloaded.Memory.dll and /dev/null differ diff --git a/jackify/engine/SHA3.Net.dll b/jackify/engine/SHA3.Net.dll deleted file mode 100755 index 9230fe2..0000000 Binary files a/jackify/engine/SHA3.Net.dll and /dev/null differ diff --git a/jackify/engine/SQLite.Interop.dll b/jackify/engine/SQLite.Interop.dll deleted file mode 100755 index 6cc6b46..0000000 Binary files a/jackify/engine/SQLite.Interop.dll and /dev/null differ diff --git a/jackify/engine/SharpCompress.dll b/jackify/engine/SharpCompress.dll deleted file mode 100644 index 13dced8..0000000 Binary files a/jackify/engine/SharpCompress.dll and /dev/null differ diff --git a/jackify/engine/Shipwreck.Phash.dll b/jackify/engine/Shipwreck.Phash.dll deleted file mode 100644 index ffc70df..0000000 Binary files a/jackify/engine/Shipwreck.Phash.dll and /dev/null differ diff --git a/jackify/engine/SixLabors.ImageSharp.dll b/jackify/engine/SixLabors.ImageSharp.dll deleted file mode 100644 index 3850cf8..0000000 Binary files a/jackify/engine/SixLabors.ImageSharp.dll and /dev/null differ diff --git a/jackify/engine/System.AppContext.dll b/jackify/engine/System.AppContext.dll deleted file mode 100755 index 2fdaaf6..0000000 Binary files a/jackify/engine/System.AppContext.dll and /dev/null differ diff --git a/jackify/engine/System.Buffers.dll b/jackify/engine/System.Buffers.dll deleted file mode 100755 index 8dc191c..0000000 Binary files a/jackify/engine/System.Buffers.dll and /dev/null differ diff --git a/jackify/engine/System.CodeDom.dll b/jackify/engine/System.CodeDom.dll deleted file mode 100755 index 81713c8..0000000 Binary files a/jackify/engine/System.CodeDom.dll and /dev/null differ diff --git a/jackify/engine/System.Collections.Concurrent.dll b/jackify/engine/System.Collections.Concurrent.dll deleted file mode 100755 index 23e8b14..0000000 Binary files a/jackify/engine/System.Collections.Concurrent.dll and /dev/null differ diff --git a/jackify/engine/System.Collections.Immutable.dll b/jackify/engine/System.Collections.Immutable.dll deleted file mode 100755 index b4addcf..0000000 Binary files a/jackify/engine/System.Collections.Immutable.dll and /dev/null differ diff --git a/jackify/engine/System.Collections.NonGeneric.dll b/jackify/engine/System.Collections.NonGeneric.dll deleted file mode 100755 index 911c9ec..0000000 Binary files a/jackify/engine/System.Collections.NonGeneric.dll and /dev/null differ diff --git a/jackify/engine/System.Collections.Specialized.dll b/jackify/engine/System.Collections.Specialized.dll deleted file mode 100755 index 91da803..0000000 Binary files a/jackify/engine/System.Collections.Specialized.dll and /dev/null differ diff --git a/jackify/engine/System.Collections.dll b/jackify/engine/System.Collections.dll deleted file mode 100755 index ede76b9..0000000 Binary files a/jackify/engine/System.Collections.dll and /dev/null differ diff --git a/jackify/engine/System.CommandLine.NamingConventionBinder.dll b/jackify/engine/System.CommandLine.NamingConventionBinder.dll deleted file mode 100644 index 7c873b8..0000000 Binary files a/jackify/engine/System.CommandLine.NamingConventionBinder.dll and /dev/null differ diff --git a/jackify/engine/System.CommandLine.dll b/jackify/engine/System.CommandLine.dll deleted file mode 100644 index eb30bc4..0000000 Binary files a/jackify/engine/System.CommandLine.dll and /dev/null differ diff --git a/jackify/engine/System.ComponentModel.Annotations.dll b/jackify/engine/System.ComponentModel.Annotations.dll deleted file mode 100755 index 1184ec8..0000000 Binary files a/jackify/engine/System.ComponentModel.Annotations.dll and /dev/null differ diff --git a/jackify/engine/System.ComponentModel.DataAnnotations.dll b/jackify/engine/System.ComponentModel.DataAnnotations.dll deleted file mode 100755 index f180671..0000000 Binary files a/jackify/engine/System.ComponentModel.DataAnnotations.dll and /dev/null differ diff --git a/jackify/engine/System.ComponentModel.EventBasedAsync.dll b/jackify/engine/System.ComponentModel.EventBasedAsync.dll deleted file mode 100755 index 7013aa1..0000000 Binary files a/jackify/engine/System.ComponentModel.EventBasedAsync.dll and /dev/null differ diff --git a/jackify/engine/System.ComponentModel.Primitives.dll b/jackify/engine/System.ComponentModel.Primitives.dll deleted file mode 100755 index c93d117..0000000 Binary files a/jackify/engine/System.ComponentModel.Primitives.dll and /dev/null differ diff --git a/jackify/engine/System.ComponentModel.TypeConverter.dll b/jackify/engine/System.ComponentModel.TypeConverter.dll deleted file mode 100755 index 6a6d703..0000000 Binary files a/jackify/engine/System.ComponentModel.TypeConverter.dll and /dev/null differ diff --git a/jackify/engine/System.ComponentModel.dll b/jackify/engine/System.ComponentModel.dll deleted file mode 100755 index 32e34ca..0000000 Binary files a/jackify/engine/System.ComponentModel.dll and /dev/null differ diff --git a/jackify/engine/System.Configuration.dll b/jackify/engine/System.Configuration.dll deleted file mode 100755 index 530cd5a..0000000 Binary files a/jackify/engine/System.Configuration.dll and /dev/null differ diff --git a/jackify/engine/System.Console.dll b/jackify/engine/System.Console.dll deleted file mode 100755 index 93aa65d..0000000 Binary files a/jackify/engine/System.Console.dll and /dev/null differ diff --git a/jackify/engine/System.Core.dll b/jackify/engine/System.Core.dll deleted file mode 100755 index 1674365..0000000 Binary files a/jackify/engine/System.Core.dll and /dev/null differ diff --git a/jackify/engine/System.Data.Common.dll b/jackify/engine/System.Data.Common.dll deleted file mode 100755 index 701c7f8..0000000 Binary files a/jackify/engine/System.Data.Common.dll and /dev/null differ diff --git a/jackify/engine/System.Data.DataSetExtensions.dll b/jackify/engine/System.Data.DataSetExtensions.dll deleted file mode 100755 index d387f61..0000000 Binary files a/jackify/engine/System.Data.DataSetExtensions.dll and /dev/null differ diff --git a/jackify/engine/System.Data.SQLite.dll b/jackify/engine/System.Data.SQLite.dll deleted file mode 100644 index e9c50fe..0000000 Binary files a/jackify/engine/System.Data.SQLite.dll and /dev/null differ diff --git a/jackify/engine/System.Data.dll b/jackify/engine/System.Data.dll deleted file mode 100755 index 7d801e7..0000000 Binary files a/jackify/engine/System.Data.dll and /dev/null differ diff --git a/jackify/engine/System.Diagnostics.Contracts.dll b/jackify/engine/System.Diagnostics.Contracts.dll deleted file mode 100755 index 1914bce..0000000 Binary files a/jackify/engine/System.Diagnostics.Contracts.dll and /dev/null differ diff --git a/jackify/engine/System.Diagnostics.Debug.dll b/jackify/engine/System.Diagnostics.Debug.dll deleted file mode 100755 index 389c116..0000000 Binary files a/jackify/engine/System.Diagnostics.Debug.dll and /dev/null differ diff --git a/jackify/engine/System.Diagnostics.DiagnosticSource.dll b/jackify/engine/System.Diagnostics.DiagnosticSource.dll deleted file mode 100644 index d1ae9cf..0000000 Binary files a/jackify/engine/System.Diagnostics.DiagnosticSource.dll and /dev/null differ diff --git a/jackify/engine/System.Diagnostics.EventLog.dll b/jackify/engine/System.Diagnostics.EventLog.dll deleted file mode 100644 index f29dbdc..0000000 Binary files a/jackify/engine/System.Diagnostics.EventLog.dll and /dev/null differ diff --git a/jackify/engine/System.Diagnostics.FileVersionInfo.dll b/jackify/engine/System.Diagnostics.FileVersionInfo.dll deleted file mode 100755 index 1da3cf1..0000000 Binary files a/jackify/engine/System.Diagnostics.FileVersionInfo.dll and /dev/null differ diff --git a/jackify/engine/System.Diagnostics.Process.dll b/jackify/engine/System.Diagnostics.Process.dll deleted file mode 100755 index 0c9073b..0000000 Binary files a/jackify/engine/System.Diagnostics.Process.dll and /dev/null differ diff --git a/jackify/engine/System.Diagnostics.StackTrace.dll b/jackify/engine/System.Diagnostics.StackTrace.dll deleted file mode 100755 index 89f77e3..0000000 Binary files a/jackify/engine/System.Diagnostics.StackTrace.dll and /dev/null differ diff --git a/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll b/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll deleted file mode 100755 index 05b2822..0000000 Binary files a/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll and /dev/null differ diff --git a/jackify/engine/System.Diagnostics.Tools.dll b/jackify/engine/System.Diagnostics.Tools.dll deleted file mode 100755 index 50f2ce2..0000000 Binary files a/jackify/engine/System.Diagnostics.Tools.dll and /dev/null differ diff --git a/jackify/engine/System.Diagnostics.TraceSource.dll b/jackify/engine/System.Diagnostics.TraceSource.dll deleted file mode 100755 index 624b9e0..0000000 Binary files a/jackify/engine/System.Diagnostics.TraceSource.dll and /dev/null differ diff --git a/jackify/engine/System.Diagnostics.Tracing.dll b/jackify/engine/System.Diagnostics.Tracing.dll deleted file mode 100755 index 7d36735..0000000 Binary files a/jackify/engine/System.Diagnostics.Tracing.dll and /dev/null differ diff --git a/jackify/engine/System.Drawing.Common.dll b/jackify/engine/System.Drawing.Common.dll deleted file mode 100755 index dc2ec68..0000000 Binary files a/jackify/engine/System.Drawing.Common.dll and /dev/null differ diff --git a/jackify/engine/System.Drawing.Primitives.dll b/jackify/engine/System.Drawing.Primitives.dll deleted file mode 100755 index 4fc521b..0000000 Binary files a/jackify/engine/System.Drawing.Primitives.dll and /dev/null differ diff --git a/jackify/engine/System.Drawing.dll b/jackify/engine/System.Drawing.dll deleted file mode 100755 index b99d1c0..0000000 Binary files a/jackify/engine/System.Drawing.dll and /dev/null differ diff --git a/jackify/engine/System.Dynamic.Runtime.dll b/jackify/engine/System.Dynamic.Runtime.dll deleted file mode 100755 index 30d1228..0000000 Binary files a/jackify/engine/System.Dynamic.Runtime.dll and /dev/null differ diff --git a/jackify/engine/System.Formats.Asn1.dll b/jackify/engine/System.Formats.Asn1.dll deleted file mode 100755 index ca061cf..0000000 Binary files a/jackify/engine/System.Formats.Asn1.dll and /dev/null differ diff --git a/jackify/engine/System.Formats.Tar.dll b/jackify/engine/System.Formats.Tar.dll deleted file mode 100755 index f5d84f3..0000000 Binary files a/jackify/engine/System.Formats.Tar.dll and /dev/null differ diff --git a/jackify/engine/System.Globalization.Calendars.dll b/jackify/engine/System.Globalization.Calendars.dll deleted file mode 100755 index a7e203d..0000000 Binary files a/jackify/engine/System.Globalization.Calendars.dll and /dev/null differ diff --git a/jackify/engine/System.Globalization.Extensions.dll b/jackify/engine/System.Globalization.Extensions.dll deleted file mode 100755 index 809a762..0000000 Binary files a/jackify/engine/System.Globalization.Extensions.dll and /dev/null differ diff --git a/jackify/engine/System.Globalization.dll b/jackify/engine/System.Globalization.dll deleted file mode 100755 index e74c9b4..0000000 Binary files a/jackify/engine/System.Globalization.dll and /dev/null differ diff --git a/jackify/engine/System.IO.Compression.Brotli.dll b/jackify/engine/System.IO.Compression.Brotli.dll deleted file mode 100755 index 75da6b8..0000000 Binary files a/jackify/engine/System.IO.Compression.Brotli.dll and /dev/null differ diff --git a/jackify/engine/System.IO.Compression.FileSystem.dll b/jackify/engine/System.IO.Compression.FileSystem.dll deleted file mode 100755 index 120573a..0000000 Binary files a/jackify/engine/System.IO.Compression.FileSystem.dll and /dev/null differ diff --git a/jackify/engine/System.IO.Compression.ZipFile.dll b/jackify/engine/System.IO.Compression.ZipFile.dll deleted file mode 100755 index ced8fd3..0000000 Binary files a/jackify/engine/System.IO.Compression.ZipFile.dll and /dev/null differ diff --git a/jackify/engine/System.IO.Compression.dll b/jackify/engine/System.IO.Compression.dll deleted file mode 100755 index 7413a58..0000000 Binary files a/jackify/engine/System.IO.Compression.dll and /dev/null differ diff --git a/jackify/engine/System.IO.FileSystem.AccessControl.dll b/jackify/engine/System.IO.FileSystem.AccessControl.dll deleted file mode 100755 index 050e12e..0000000 Binary files a/jackify/engine/System.IO.FileSystem.AccessControl.dll and /dev/null differ diff --git a/jackify/engine/System.IO.FileSystem.DriveInfo.dll b/jackify/engine/System.IO.FileSystem.DriveInfo.dll deleted file mode 100755 index 0f87e79..0000000 Binary files a/jackify/engine/System.IO.FileSystem.DriveInfo.dll and /dev/null differ diff --git a/jackify/engine/System.IO.FileSystem.Primitives.dll b/jackify/engine/System.IO.FileSystem.Primitives.dll deleted file mode 100755 index 958aca0..0000000 Binary files a/jackify/engine/System.IO.FileSystem.Primitives.dll and /dev/null differ diff --git a/jackify/engine/System.IO.FileSystem.Watcher.dll b/jackify/engine/System.IO.FileSystem.Watcher.dll deleted file mode 100755 index 49194f4..0000000 Binary files a/jackify/engine/System.IO.FileSystem.Watcher.dll and /dev/null differ diff --git a/jackify/engine/System.IO.FileSystem.dll b/jackify/engine/System.IO.FileSystem.dll deleted file mode 100755 index bf65e6a..0000000 Binary files a/jackify/engine/System.IO.FileSystem.dll and /dev/null differ diff --git a/jackify/engine/System.IO.IsolatedStorage.dll b/jackify/engine/System.IO.IsolatedStorage.dll deleted file mode 100755 index fdc17ba..0000000 Binary files a/jackify/engine/System.IO.IsolatedStorage.dll and /dev/null differ diff --git a/jackify/engine/System.IO.MemoryMappedFiles.dll b/jackify/engine/System.IO.MemoryMappedFiles.dll deleted file mode 100755 index 4c0acf0..0000000 Binary files a/jackify/engine/System.IO.MemoryMappedFiles.dll and /dev/null differ diff --git a/jackify/engine/System.IO.Pipelines.dll b/jackify/engine/System.IO.Pipelines.dll deleted file mode 100644 index c7941db..0000000 Binary files a/jackify/engine/System.IO.Pipelines.dll and /dev/null differ diff --git a/jackify/engine/System.IO.Pipes.AccessControl.dll b/jackify/engine/System.IO.Pipes.AccessControl.dll deleted file mode 100755 index e5c7b4a..0000000 Binary files a/jackify/engine/System.IO.Pipes.AccessControl.dll and /dev/null differ diff --git a/jackify/engine/System.IO.Pipes.dll b/jackify/engine/System.IO.Pipes.dll deleted file mode 100755 index 43d480e..0000000 Binary files a/jackify/engine/System.IO.Pipes.dll and /dev/null differ diff --git a/jackify/engine/System.IO.UnmanagedMemoryStream.dll b/jackify/engine/System.IO.UnmanagedMemoryStream.dll deleted file mode 100755 index fc88b14..0000000 Binary files a/jackify/engine/System.IO.UnmanagedMemoryStream.dll and /dev/null differ diff --git a/jackify/engine/System.IO.dll b/jackify/engine/System.IO.dll deleted file mode 100755 index 5b4ebe6..0000000 Binary files a/jackify/engine/System.IO.dll and /dev/null differ diff --git a/jackify/engine/System.Linq.Expressions.dll b/jackify/engine/System.Linq.Expressions.dll deleted file mode 100755 index d43cfd2..0000000 Binary files a/jackify/engine/System.Linq.Expressions.dll and /dev/null differ diff --git a/jackify/engine/System.Linq.Parallel.dll b/jackify/engine/System.Linq.Parallel.dll deleted file mode 100755 index 6dc8e04..0000000 Binary files a/jackify/engine/System.Linq.Parallel.dll and /dev/null differ diff --git a/jackify/engine/System.Linq.Queryable.dll b/jackify/engine/System.Linq.Queryable.dll deleted file mode 100755 index d789374..0000000 Binary files a/jackify/engine/System.Linq.Queryable.dll and /dev/null differ diff --git a/jackify/engine/System.Linq.dll b/jackify/engine/System.Linq.dll deleted file mode 100755 index a5b2d5a..0000000 Binary files a/jackify/engine/System.Linq.dll and /dev/null differ diff --git a/jackify/engine/System.Management.dll b/jackify/engine/System.Management.dll deleted file mode 100755 index e67a923..0000000 Binary files a/jackify/engine/System.Management.dll and /dev/null differ diff --git a/jackify/engine/System.Memory.dll b/jackify/engine/System.Memory.dll deleted file mode 100755 index 97b1544..0000000 Binary files a/jackify/engine/System.Memory.dll and /dev/null differ diff --git a/jackify/engine/System.Net.Http.Json.dll b/jackify/engine/System.Net.Http.Json.dll deleted file mode 100755 index d9c089e..0000000 Binary files a/jackify/engine/System.Net.Http.Json.dll and /dev/null differ diff --git a/jackify/engine/System.Net.Http.dll b/jackify/engine/System.Net.Http.dll deleted file mode 100755 index 787b7c6..0000000 Binary files a/jackify/engine/System.Net.Http.dll and /dev/null differ diff --git a/jackify/engine/System.Net.HttpListener.dll b/jackify/engine/System.Net.HttpListener.dll deleted file mode 100755 index 41945ef..0000000 Binary files a/jackify/engine/System.Net.HttpListener.dll and /dev/null differ diff --git a/jackify/engine/System.Net.Mail.dll b/jackify/engine/System.Net.Mail.dll deleted file mode 100755 index a91923f..0000000 Binary files a/jackify/engine/System.Net.Mail.dll and /dev/null differ diff --git a/jackify/engine/System.Net.NameResolution.dll b/jackify/engine/System.Net.NameResolution.dll deleted file mode 100755 index db26d58..0000000 Binary files a/jackify/engine/System.Net.NameResolution.dll and /dev/null differ diff --git a/jackify/engine/System.Net.NetworkInformation.dll b/jackify/engine/System.Net.NetworkInformation.dll deleted file mode 100755 index d59b2fd..0000000 Binary files a/jackify/engine/System.Net.NetworkInformation.dll and /dev/null differ diff --git a/jackify/engine/System.Net.Ping.dll b/jackify/engine/System.Net.Ping.dll deleted file mode 100755 index 91b8970..0000000 Binary files a/jackify/engine/System.Net.Ping.dll and /dev/null differ diff --git a/jackify/engine/System.Net.Primitives.dll b/jackify/engine/System.Net.Primitives.dll deleted file mode 100755 index 6a769b7..0000000 Binary files a/jackify/engine/System.Net.Primitives.dll and /dev/null differ diff --git a/jackify/engine/System.Net.Quic.dll b/jackify/engine/System.Net.Quic.dll deleted file mode 100755 index 2573e65..0000000 Binary files a/jackify/engine/System.Net.Quic.dll and /dev/null differ diff --git a/jackify/engine/System.Net.Requests.dll b/jackify/engine/System.Net.Requests.dll deleted file mode 100755 index d3116cc..0000000 Binary files a/jackify/engine/System.Net.Requests.dll and /dev/null differ diff --git a/jackify/engine/System.Net.Security.dll b/jackify/engine/System.Net.Security.dll deleted file mode 100755 index 4ec6d6a..0000000 Binary files a/jackify/engine/System.Net.Security.dll and /dev/null differ diff --git a/jackify/engine/System.Net.ServicePoint.dll b/jackify/engine/System.Net.ServicePoint.dll deleted file mode 100755 index 7fa9dd0..0000000 Binary files a/jackify/engine/System.Net.ServicePoint.dll and /dev/null differ diff --git a/jackify/engine/System.Net.Sockets.dll b/jackify/engine/System.Net.Sockets.dll deleted file mode 100755 index f67fa8c..0000000 Binary files a/jackify/engine/System.Net.Sockets.dll and /dev/null differ diff --git a/jackify/engine/System.Net.WebClient.dll b/jackify/engine/System.Net.WebClient.dll deleted file mode 100755 index 42dfca7..0000000 Binary files a/jackify/engine/System.Net.WebClient.dll and /dev/null differ diff --git a/jackify/engine/System.Net.WebHeaderCollection.dll b/jackify/engine/System.Net.WebHeaderCollection.dll deleted file mode 100755 index f0d743d..0000000 Binary files a/jackify/engine/System.Net.WebHeaderCollection.dll and /dev/null differ diff --git a/jackify/engine/System.Net.WebProxy.dll b/jackify/engine/System.Net.WebProxy.dll deleted file mode 100755 index 0b9eed1..0000000 Binary files a/jackify/engine/System.Net.WebProxy.dll and /dev/null differ diff --git a/jackify/engine/System.Net.WebSockets.Client.dll b/jackify/engine/System.Net.WebSockets.Client.dll deleted file mode 100755 index 50baafa..0000000 Binary files a/jackify/engine/System.Net.WebSockets.Client.dll and /dev/null differ diff --git a/jackify/engine/System.Net.WebSockets.dll b/jackify/engine/System.Net.WebSockets.dll deleted file mode 100755 index 335b20d..0000000 Binary files a/jackify/engine/System.Net.WebSockets.dll and /dev/null differ diff --git a/jackify/engine/System.Net.dll b/jackify/engine/System.Net.dll deleted file mode 100755 index f86fe00..0000000 Binary files a/jackify/engine/System.Net.dll and /dev/null differ diff --git a/jackify/engine/System.Numerics.Vectors.dll b/jackify/engine/System.Numerics.Vectors.dll deleted file mode 100755 index 30b18be..0000000 Binary files a/jackify/engine/System.Numerics.Vectors.dll and /dev/null differ diff --git a/jackify/engine/System.Numerics.dll b/jackify/engine/System.Numerics.dll deleted file mode 100755 index fec68c7..0000000 Binary files a/jackify/engine/System.Numerics.dll and /dev/null differ diff --git a/jackify/engine/System.ObjectModel.dll b/jackify/engine/System.ObjectModel.dll deleted file mode 100755 index 5317923..0000000 Binary files a/jackify/engine/System.ObjectModel.dll and /dev/null differ diff --git a/jackify/engine/System.Private.CoreLib.dll b/jackify/engine/System.Private.CoreLib.dll deleted file mode 100755 index 8a161dc..0000000 Binary files a/jackify/engine/System.Private.CoreLib.dll and /dev/null differ diff --git a/jackify/engine/System.Private.DataContractSerialization.dll b/jackify/engine/System.Private.DataContractSerialization.dll deleted file mode 100755 index 12bc29f..0000000 Binary files a/jackify/engine/System.Private.DataContractSerialization.dll and /dev/null differ diff --git a/jackify/engine/System.Private.Uri.dll b/jackify/engine/System.Private.Uri.dll deleted file mode 100755 index 5190647..0000000 Binary files a/jackify/engine/System.Private.Uri.dll and /dev/null differ diff --git a/jackify/engine/System.Private.Xml.Linq.dll b/jackify/engine/System.Private.Xml.Linq.dll deleted file mode 100755 index b0043cc..0000000 Binary files a/jackify/engine/System.Private.Xml.Linq.dll and /dev/null differ diff --git a/jackify/engine/System.Private.Xml.dll b/jackify/engine/System.Private.Xml.dll deleted file mode 100755 index 3a2ad5c..0000000 Binary files a/jackify/engine/System.Private.Xml.dll and /dev/null differ diff --git a/jackify/engine/System.Reactive.dll b/jackify/engine/System.Reactive.dll deleted file mode 100644 index d4bbc17..0000000 Binary files a/jackify/engine/System.Reactive.dll and /dev/null differ diff --git a/jackify/engine/System.Reflection.DispatchProxy.dll b/jackify/engine/System.Reflection.DispatchProxy.dll deleted file mode 100755 index c449d29..0000000 Binary files a/jackify/engine/System.Reflection.DispatchProxy.dll and /dev/null differ diff --git a/jackify/engine/System.Reflection.Emit.ILGeneration.dll b/jackify/engine/System.Reflection.Emit.ILGeneration.dll deleted file mode 100755 index e3e39b9..0000000 Binary files a/jackify/engine/System.Reflection.Emit.ILGeneration.dll and /dev/null differ diff --git a/jackify/engine/System.Reflection.Emit.Lightweight.dll b/jackify/engine/System.Reflection.Emit.Lightweight.dll deleted file mode 100755 index f02499b..0000000 Binary files a/jackify/engine/System.Reflection.Emit.Lightweight.dll and /dev/null differ diff --git a/jackify/engine/System.Reflection.Emit.dll b/jackify/engine/System.Reflection.Emit.dll deleted file mode 100755 index 3419d6d..0000000 Binary files a/jackify/engine/System.Reflection.Emit.dll and /dev/null differ diff --git a/jackify/engine/System.Reflection.Extensions.dll b/jackify/engine/System.Reflection.Extensions.dll deleted file mode 100755 index f92002b..0000000 Binary files a/jackify/engine/System.Reflection.Extensions.dll and /dev/null differ diff --git a/jackify/engine/System.Reflection.Metadata.dll b/jackify/engine/System.Reflection.Metadata.dll deleted file mode 100755 index 40f197e..0000000 Binary files a/jackify/engine/System.Reflection.Metadata.dll and /dev/null differ diff --git a/jackify/engine/System.Reflection.Primitives.dll b/jackify/engine/System.Reflection.Primitives.dll deleted file mode 100755 index f0e1dbf..0000000 Binary files a/jackify/engine/System.Reflection.Primitives.dll and /dev/null differ diff --git a/jackify/engine/System.Reflection.TypeExtensions.dll b/jackify/engine/System.Reflection.TypeExtensions.dll deleted file mode 100755 index 01a07bc..0000000 Binary files a/jackify/engine/System.Reflection.TypeExtensions.dll and /dev/null differ diff --git a/jackify/engine/System.Reflection.dll b/jackify/engine/System.Reflection.dll deleted file mode 100755 index dfa32a0..0000000 Binary files a/jackify/engine/System.Reflection.dll and /dev/null differ diff --git a/jackify/engine/System.Resources.Reader.dll b/jackify/engine/System.Resources.Reader.dll deleted file mode 100755 index 109f363..0000000 Binary files a/jackify/engine/System.Resources.Reader.dll and /dev/null differ diff --git a/jackify/engine/System.Resources.ResourceManager.dll b/jackify/engine/System.Resources.ResourceManager.dll deleted file mode 100755 index 7a1abb0..0000000 Binary files a/jackify/engine/System.Resources.ResourceManager.dll and /dev/null differ diff --git a/jackify/engine/System.Resources.Writer.dll b/jackify/engine/System.Resources.Writer.dll deleted file mode 100755 index d55f1c9..0000000 Binary files a/jackify/engine/System.Resources.Writer.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll b/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll deleted file mode 100755 index 944370b..0000000 Binary files a/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.CompilerServices.VisualC.dll b/jackify/engine/System.Runtime.CompilerServices.VisualC.dll deleted file mode 100755 index 0e3e2a4..0000000 Binary files a/jackify/engine/System.Runtime.CompilerServices.VisualC.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.Extensions.dll b/jackify/engine/System.Runtime.Extensions.dll deleted file mode 100755 index 6c4ba46..0000000 Binary files a/jackify/engine/System.Runtime.Extensions.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.Handles.dll b/jackify/engine/System.Runtime.Handles.dll deleted file mode 100755 index d4fca51..0000000 Binary files a/jackify/engine/System.Runtime.Handles.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.InteropServices.JavaScript.dll b/jackify/engine/System.Runtime.InteropServices.JavaScript.dll deleted file mode 100755 index 3e9bac7..0000000 Binary files a/jackify/engine/System.Runtime.InteropServices.JavaScript.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll b/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll deleted file mode 100755 index d66c1cc..0000000 Binary files a/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.InteropServices.dll b/jackify/engine/System.Runtime.InteropServices.dll deleted file mode 100755 index 1e44106..0000000 Binary files a/jackify/engine/System.Runtime.InteropServices.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.Intrinsics.dll b/jackify/engine/System.Runtime.Intrinsics.dll deleted file mode 100755 index e66b6a0..0000000 Binary files a/jackify/engine/System.Runtime.Intrinsics.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.Loader.dll b/jackify/engine/System.Runtime.Loader.dll deleted file mode 100755 index 451e1e2..0000000 Binary files a/jackify/engine/System.Runtime.Loader.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.Numerics.dll b/jackify/engine/System.Runtime.Numerics.dll deleted file mode 100755 index 47f9bbd..0000000 Binary files a/jackify/engine/System.Runtime.Numerics.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.Serialization.Formatters.dll b/jackify/engine/System.Runtime.Serialization.Formatters.dll deleted file mode 100755 index 835a2e7..0000000 Binary files a/jackify/engine/System.Runtime.Serialization.Formatters.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.Serialization.Json.dll b/jackify/engine/System.Runtime.Serialization.Json.dll deleted file mode 100755 index 744ac8b..0000000 Binary files a/jackify/engine/System.Runtime.Serialization.Json.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.Serialization.Primitives.dll b/jackify/engine/System.Runtime.Serialization.Primitives.dll deleted file mode 100755 index 2e287db..0000000 Binary files a/jackify/engine/System.Runtime.Serialization.Primitives.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.Serialization.Xml.dll b/jackify/engine/System.Runtime.Serialization.Xml.dll deleted file mode 100755 index 958443c..0000000 Binary files a/jackify/engine/System.Runtime.Serialization.Xml.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.Serialization.dll b/jackify/engine/System.Runtime.Serialization.dll deleted file mode 100755 index 58c72fc..0000000 Binary files a/jackify/engine/System.Runtime.Serialization.dll and /dev/null differ diff --git a/jackify/engine/System.Runtime.dll b/jackify/engine/System.Runtime.dll deleted file mode 100755 index 9da9cff..0000000 Binary files a/jackify/engine/System.Runtime.dll and /dev/null differ diff --git a/jackify/engine/System.Security.AccessControl.dll b/jackify/engine/System.Security.AccessControl.dll deleted file mode 100755 index b7827c9..0000000 Binary files a/jackify/engine/System.Security.AccessControl.dll and /dev/null differ diff --git a/jackify/engine/System.Security.Claims.dll b/jackify/engine/System.Security.Claims.dll deleted file mode 100755 index 4dd751f..0000000 Binary files a/jackify/engine/System.Security.Claims.dll and /dev/null differ diff --git a/jackify/engine/System.Security.Cryptography.Algorithms.dll b/jackify/engine/System.Security.Cryptography.Algorithms.dll deleted file mode 100755 index 5ca2bbd..0000000 Binary files a/jackify/engine/System.Security.Cryptography.Algorithms.dll and /dev/null differ diff --git a/jackify/engine/System.Security.Cryptography.Cng.dll b/jackify/engine/System.Security.Cryptography.Cng.dll deleted file mode 100755 index 8b3caae..0000000 Binary files a/jackify/engine/System.Security.Cryptography.Cng.dll and /dev/null differ diff --git a/jackify/engine/System.Security.Cryptography.Csp.dll b/jackify/engine/System.Security.Cryptography.Csp.dll deleted file mode 100755 index dbac49e..0000000 Binary files a/jackify/engine/System.Security.Cryptography.Csp.dll and /dev/null differ diff --git a/jackify/engine/System.Security.Cryptography.Encoding.dll b/jackify/engine/System.Security.Cryptography.Encoding.dll deleted file mode 100755 index da057fa..0000000 Binary files a/jackify/engine/System.Security.Cryptography.Encoding.dll and /dev/null differ diff --git a/jackify/engine/System.Security.Cryptography.OpenSsl.dll b/jackify/engine/System.Security.Cryptography.OpenSsl.dll deleted file mode 100755 index a24086c..0000000 Binary files a/jackify/engine/System.Security.Cryptography.OpenSsl.dll and /dev/null differ diff --git a/jackify/engine/System.Security.Cryptography.Primitives.dll b/jackify/engine/System.Security.Cryptography.Primitives.dll deleted file mode 100755 index 8cb1022..0000000 Binary files a/jackify/engine/System.Security.Cryptography.Primitives.dll and /dev/null differ diff --git a/jackify/engine/System.Security.Cryptography.X509Certificates.dll b/jackify/engine/System.Security.Cryptography.X509Certificates.dll deleted file mode 100755 index 306d043..0000000 Binary files a/jackify/engine/System.Security.Cryptography.X509Certificates.dll and /dev/null differ diff --git a/jackify/engine/System.Security.Cryptography.dll b/jackify/engine/System.Security.Cryptography.dll deleted file mode 100755 index 3ce8144..0000000 Binary files a/jackify/engine/System.Security.Cryptography.dll and /dev/null differ diff --git a/jackify/engine/System.Security.Principal.Windows.dll b/jackify/engine/System.Security.Principal.Windows.dll deleted file mode 100755 index 510570b..0000000 Binary files a/jackify/engine/System.Security.Principal.Windows.dll and /dev/null differ diff --git a/jackify/engine/System.Security.Principal.dll b/jackify/engine/System.Security.Principal.dll deleted file mode 100755 index d8475c1..0000000 Binary files a/jackify/engine/System.Security.Principal.dll and /dev/null differ diff --git a/jackify/engine/System.Security.SecureString.dll b/jackify/engine/System.Security.SecureString.dll deleted file mode 100755 index 9463993..0000000 Binary files a/jackify/engine/System.Security.SecureString.dll and /dev/null differ diff --git a/jackify/engine/System.Security.dll b/jackify/engine/System.Security.dll deleted file mode 100755 index c25b992..0000000 Binary files a/jackify/engine/System.Security.dll and /dev/null differ diff --git a/jackify/engine/System.ServiceModel.Web.dll b/jackify/engine/System.ServiceModel.Web.dll deleted file mode 100755 index ee0999a..0000000 Binary files a/jackify/engine/System.ServiceModel.Web.dll and /dev/null differ diff --git a/jackify/engine/System.ServiceProcess.dll b/jackify/engine/System.ServiceProcess.dll deleted file mode 100755 index a30eb92..0000000 Binary files a/jackify/engine/System.ServiceProcess.dll and /dev/null differ diff --git a/jackify/engine/System.Text.Encoding.CodePages.dll b/jackify/engine/System.Text.Encoding.CodePages.dll deleted file mode 100755 index 3c60ce8..0000000 Binary files a/jackify/engine/System.Text.Encoding.CodePages.dll and /dev/null differ diff --git a/jackify/engine/System.Text.Encoding.Extensions.dll b/jackify/engine/System.Text.Encoding.Extensions.dll deleted file mode 100755 index 5d89c32..0000000 Binary files a/jackify/engine/System.Text.Encoding.Extensions.dll and /dev/null differ diff --git a/jackify/engine/System.Text.Encoding.dll b/jackify/engine/System.Text.Encoding.dll deleted file mode 100755 index 9295480..0000000 Binary files a/jackify/engine/System.Text.Encoding.dll and /dev/null differ diff --git a/jackify/engine/System.Text.Encodings.Web.dll b/jackify/engine/System.Text.Encodings.Web.dll deleted file mode 100644 index 5f5a85f..0000000 Binary files a/jackify/engine/System.Text.Encodings.Web.dll and /dev/null differ diff --git a/jackify/engine/System.Text.Json.dll b/jackify/engine/System.Text.Json.dll deleted file mode 100644 index 6f459c4..0000000 Binary files a/jackify/engine/System.Text.Json.dll and /dev/null differ diff --git a/jackify/engine/System.Text.RegularExpressions.dll b/jackify/engine/System.Text.RegularExpressions.dll deleted file mode 100755 index 34b875e..0000000 Binary files a/jackify/engine/System.Text.RegularExpressions.dll and /dev/null differ diff --git a/jackify/engine/System.Threading.Channels.dll b/jackify/engine/System.Threading.Channels.dll deleted file mode 100755 index 1700313..0000000 Binary files a/jackify/engine/System.Threading.Channels.dll and /dev/null differ diff --git a/jackify/engine/System.Threading.Overlapped.dll b/jackify/engine/System.Threading.Overlapped.dll deleted file mode 100755 index a845c10..0000000 Binary files a/jackify/engine/System.Threading.Overlapped.dll and /dev/null differ diff --git a/jackify/engine/System.Threading.Tasks.Dataflow.dll b/jackify/engine/System.Threading.Tasks.Dataflow.dll deleted file mode 100755 index 37eab19..0000000 Binary files a/jackify/engine/System.Threading.Tasks.Dataflow.dll and /dev/null differ diff --git a/jackify/engine/System.Threading.Tasks.Extensions.dll b/jackify/engine/System.Threading.Tasks.Extensions.dll deleted file mode 100755 index a8e125c..0000000 Binary files a/jackify/engine/System.Threading.Tasks.Extensions.dll and /dev/null differ diff --git a/jackify/engine/System.Threading.Tasks.Parallel.dll b/jackify/engine/System.Threading.Tasks.Parallel.dll deleted file mode 100755 index c9cba92..0000000 Binary files a/jackify/engine/System.Threading.Tasks.Parallel.dll and /dev/null differ diff --git a/jackify/engine/System.Threading.Tasks.dll b/jackify/engine/System.Threading.Tasks.dll deleted file mode 100755 index 3635c28..0000000 Binary files a/jackify/engine/System.Threading.Tasks.dll and /dev/null differ diff --git a/jackify/engine/System.Threading.Thread.dll b/jackify/engine/System.Threading.Thread.dll deleted file mode 100755 index 15d5ed5..0000000 Binary files a/jackify/engine/System.Threading.Thread.dll and /dev/null differ diff --git a/jackify/engine/System.Threading.ThreadPool.dll b/jackify/engine/System.Threading.ThreadPool.dll deleted file mode 100755 index daa1938..0000000 Binary files a/jackify/engine/System.Threading.ThreadPool.dll and /dev/null differ diff --git a/jackify/engine/System.Threading.Timer.dll b/jackify/engine/System.Threading.Timer.dll deleted file mode 100755 index f09636a..0000000 Binary files a/jackify/engine/System.Threading.Timer.dll and /dev/null differ diff --git a/jackify/engine/System.Threading.dll b/jackify/engine/System.Threading.dll deleted file mode 100755 index 6cf0d46..0000000 Binary files a/jackify/engine/System.Threading.dll and /dev/null differ diff --git a/jackify/engine/System.Transactions.Local.dll b/jackify/engine/System.Transactions.Local.dll deleted file mode 100755 index 9f365a6..0000000 Binary files a/jackify/engine/System.Transactions.Local.dll and /dev/null differ diff --git a/jackify/engine/System.Transactions.dll b/jackify/engine/System.Transactions.dll deleted file mode 100755 index 5e96a9a..0000000 Binary files a/jackify/engine/System.Transactions.dll and /dev/null differ diff --git a/jackify/engine/System.ValueTuple.dll b/jackify/engine/System.ValueTuple.dll deleted file mode 100755 index 7f47b1e..0000000 Binary files a/jackify/engine/System.ValueTuple.dll and /dev/null differ diff --git a/jackify/engine/System.Web.HttpUtility.dll b/jackify/engine/System.Web.HttpUtility.dll deleted file mode 100755 index aa02b47..0000000 Binary files a/jackify/engine/System.Web.HttpUtility.dll and /dev/null differ diff --git a/jackify/engine/System.Web.dll b/jackify/engine/System.Web.dll deleted file mode 100755 index ac4807d..0000000 Binary files a/jackify/engine/System.Web.dll and /dev/null differ diff --git a/jackify/engine/System.Windows.dll b/jackify/engine/System.Windows.dll deleted file mode 100755 index 9a05d3d..0000000 Binary files a/jackify/engine/System.Windows.dll and /dev/null differ diff --git a/jackify/engine/System.Xml.Linq.dll b/jackify/engine/System.Xml.Linq.dll deleted file mode 100755 index f7f65d5..0000000 Binary files a/jackify/engine/System.Xml.Linq.dll and /dev/null differ diff --git a/jackify/engine/System.Xml.ReaderWriter.dll b/jackify/engine/System.Xml.ReaderWriter.dll deleted file mode 100755 index 5e79a7a..0000000 Binary files a/jackify/engine/System.Xml.ReaderWriter.dll and /dev/null differ diff --git a/jackify/engine/System.Xml.Serialization.dll b/jackify/engine/System.Xml.Serialization.dll deleted file mode 100755 index dcfa588..0000000 Binary files a/jackify/engine/System.Xml.Serialization.dll and /dev/null differ diff --git a/jackify/engine/System.Xml.XDocument.dll b/jackify/engine/System.Xml.XDocument.dll deleted file mode 100755 index 3474424..0000000 Binary files a/jackify/engine/System.Xml.XDocument.dll and /dev/null differ diff --git a/jackify/engine/System.Xml.XPath.XDocument.dll b/jackify/engine/System.Xml.XPath.XDocument.dll deleted file mode 100755 index 97b3635..0000000 Binary files a/jackify/engine/System.Xml.XPath.XDocument.dll and /dev/null differ diff --git a/jackify/engine/System.Xml.XPath.dll b/jackify/engine/System.Xml.XPath.dll deleted file mode 100755 index 17dba71..0000000 Binary files a/jackify/engine/System.Xml.XPath.dll and /dev/null differ diff --git a/jackify/engine/System.Xml.XmlDocument.dll b/jackify/engine/System.Xml.XmlDocument.dll deleted file mode 100755 index a36753b..0000000 Binary files a/jackify/engine/System.Xml.XmlDocument.dll and /dev/null differ diff --git a/jackify/engine/System.Xml.XmlSerializer.dll b/jackify/engine/System.Xml.XmlSerializer.dll deleted file mode 100755 index cff859a..0000000 Binary files a/jackify/engine/System.Xml.XmlSerializer.dll and /dev/null differ diff --git a/jackify/engine/System.Xml.dll b/jackify/engine/System.Xml.dll deleted file mode 100755 index 69f9bff..0000000 Binary files a/jackify/engine/System.Xml.dll and /dev/null differ diff --git a/jackify/engine/System.dll b/jackify/engine/System.dll deleted file mode 100755 index 97760b0..0000000 Binary files a/jackify/engine/System.dll and /dev/null differ diff --git a/jackify/engine/TestBackslashFix.dll b/jackify/engine/TestBackslashFix.dll deleted file mode 100644 index cb1f114..0000000 Binary files a/jackify/engine/TestBackslashFix.dll and /dev/null differ diff --git a/jackify/engine/Tools/texconv.exe b/jackify/engine/Tools/texconv.exe deleted file mode 100755 index 6ff2033..0000000 Binary files a/jackify/engine/Tools/texconv.exe and /dev/null differ diff --git a/jackify/engine/Tools/texdiag.exe b/jackify/engine/Tools/texdiag.exe deleted file mode 100644 index 604c5d3..0000000 Binary files a/jackify/engine/Tools/texdiag.exe and /dev/null differ diff --git a/jackify/engine/ValveKeyValue.dll b/jackify/engine/ValveKeyValue.dll deleted file mode 100755 index edbfea9..0000000 Binary files a/jackify/engine/ValveKeyValue.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.CLI.Builder.dll b/jackify/engine/Wabbajack.CLI.Builder.dll deleted file mode 100644 index d135111..0000000 Binary files a/jackify/engine/Wabbajack.CLI.Builder.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Common.dll b/jackify/engine/Wabbajack.Common.dll deleted file mode 100644 index 82bb8e4..0000000 Binary files a/jackify/engine/Wabbajack.Common.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Compiler.dll b/jackify/engine/Wabbajack.Compiler.dll deleted file mode 100644 index d3f3b3e..0000000 Binary files a/jackify/engine/Wabbajack.Compiler.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Compression.BSA.dll b/jackify/engine/Wabbajack.Compression.BSA.dll deleted file mode 100644 index 1ce4731..0000000 Binary files a/jackify/engine/Wabbajack.Compression.BSA.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Compression.Zip.dll b/jackify/engine/Wabbajack.Compression.Zip.dll deleted file mode 100644 index 0742573..0000000 Binary files a/jackify/engine/Wabbajack.Compression.Zip.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Configuration.dll b/jackify/engine/Wabbajack.Configuration.dll deleted file mode 100644 index 8526e16..0000000 Binary files a/jackify/engine/Wabbajack.Configuration.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.DTOs.dll b/jackify/engine/Wabbajack.DTOs.dll deleted file mode 100644 index 9eacaaa..0000000 Binary files a/jackify/engine/Wabbajack.DTOs.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.Bethesda.dll b/jackify/engine/Wabbajack.Downloaders.Bethesda.dll deleted file mode 100644 index 6cca412..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.Bethesda.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll b/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll deleted file mode 100644 index c946237..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.GameFile.dll b/jackify/engine/Wabbajack.Downloaders.GameFile.dll deleted file mode 100644 index dc6d9fb..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.GameFile.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll b/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll deleted file mode 100644 index eb1b3d0..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.Http.dll b/jackify/engine/Wabbajack.Downloaders.Http.dll deleted file mode 100644 index c68086e..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.Http.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll b/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll deleted file mode 100644 index fe00416..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.Interfaces.dll b/jackify/engine/Wabbajack.Downloaders.Interfaces.dll deleted file mode 100644 index 2befb31..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.Interfaces.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.Manual.dll b/jackify/engine/Wabbajack.Downloaders.Manual.dll deleted file mode 100644 index 37bd8b3..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.Manual.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.MediaFire.dll b/jackify/engine/Wabbajack.Downloaders.MediaFire.dll deleted file mode 100644 index 88acb31..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.MediaFire.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.Mega.dll b/jackify/engine/Wabbajack.Downloaders.Mega.dll deleted file mode 100644 index d1e5067..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.Mega.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.ModDB.dll b/jackify/engine/Wabbajack.Downloaders.ModDB.dll deleted file mode 100644 index 4d8bd38..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.ModDB.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.Nexus.dll b/jackify/engine/Wabbajack.Downloaders.Nexus.dll deleted file mode 100644 index b816874..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.Nexus.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll b/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll deleted file mode 100644 index 769a51b..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll b/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll deleted file mode 100644 index d218aab..0000000 Binary files a/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.FileExtractor.dll b/jackify/engine/Wabbajack.FileExtractor.dll deleted file mode 100644 index ec09852..0000000 Binary files a/jackify/engine/Wabbajack.FileExtractor.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Hashing.PHash.dll b/jackify/engine/Wabbajack.Hashing.PHash.dll deleted file mode 100644 index ec9b547..0000000 Binary files a/jackify/engine/Wabbajack.Hashing.PHash.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Hashing.xxHash64.dll b/jackify/engine/Wabbajack.Hashing.xxHash64.dll deleted file mode 100644 index b475687..0000000 Binary files a/jackify/engine/Wabbajack.Hashing.xxHash64.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.IO.Async.dll b/jackify/engine/Wabbajack.IO.Async.dll deleted file mode 100644 index ba08315..0000000 Binary files a/jackify/engine/Wabbajack.IO.Async.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Installer.dll b/jackify/engine/Wabbajack.Installer.dll deleted file mode 100644 index a6bbb8d..0000000 Binary files a/jackify/engine/Wabbajack.Installer.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Networking.BethesdaNet.dll b/jackify/engine/Wabbajack.Networking.BethesdaNet.dll deleted file mode 100644 index 9d07f81..0000000 Binary files a/jackify/engine/Wabbajack.Networking.BethesdaNet.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Networking.Discord.dll b/jackify/engine/Wabbajack.Networking.Discord.dll deleted file mode 100644 index f26e68e..0000000 Binary files a/jackify/engine/Wabbajack.Networking.Discord.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Networking.GitHub.dll b/jackify/engine/Wabbajack.Networking.GitHub.dll deleted file mode 100644 index 43fb46b..0000000 Binary files a/jackify/engine/Wabbajack.Networking.GitHub.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll b/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll deleted file mode 100644 index 1d7192e..0000000 Binary files a/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Networking.Http.dll b/jackify/engine/Wabbajack.Networking.Http.dll deleted file mode 100644 index c8a69df..0000000 Binary files a/jackify/engine/Wabbajack.Networking.Http.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Networking.NexusApi.dll b/jackify/engine/Wabbajack.Networking.NexusApi.dll deleted file mode 100644 index 935a41e..0000000 Binary files a/jackify/engine/Wabbajack.Networking.NexusApi.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll b/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll deleted file mode 100644 index 8c90044..0000000 Binary files a/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Paths.IO.dll b/jackify/engine/Wabbajack.Paths.IO.dll deleted file mode 100644 index 9eb0ddd..0000000 Binary files a/jackify/engine/Wabbajack.Paths.IO.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Paths.dll b/jackify/engine/Wabbajack.Paths.dll deleted file mode 100644 index 18d9d0e..0000000 Binary files a/jackify/engine/Wabbajack.Paths.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.RateLimiter.dll b/jackify/engine/Wabbajack.RateLimiter.dll deleted file mode 100644 index f89a668..0000000 Binary files a/jackify/engine/Wabbajack.RateLimiter.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Server.Lib.dll b/jackify/engine/Wabbajack.Server.Lib.dll deleted file mode 100644 index ab0ea8e..0000000 Binary files a/jackify/engine/Wabbajack.Server.Lib.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.Services.OSIntegrated.dll b/jackify/engine/Wabbajack.Services.OSIntegrated.dll deleted file mode 100644 index c6da43e..0000000 Binary files a/jackify/engine/Wabbajack.Services.OSIntegrated.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.VFS.Interfaces.dll b/jackify/engine/Wabbajack.VFS.Interfaces.dll deleted file mode 100644 index d4b06e5..0000000 Binary files a/jackify/engine/Wabbajack.VFS.Interfaces.dll and /dev/null differ diff --git a/jackify/engine/Wabbajack.VFS.dll b/jackify/engine/Wabbajack.VFS.dll deleted file mode 100644 index 735359e..0000000 Binary files a/jackify/engine/Wabbajack.VFS.dll and /dev/null differ diff --git a/jackify/engine/WindowsBase.dll b/jackify/engine/WindowsBase.dll deleted file mode 100755 index 31acb1e..0000000 Binary files a/jackify/engine/WindowsBase.dll and /dev/null differ diff --git a/jackify/engine/YamlDotNet.dll b/jackify/engine/YamlDotNet.dll deleted file mode 100644 index b18e55b..0000000 Binary files a/jackify/engine/YamlDotNet.dll and /dev/null differ diff --git a/jackify/engine/createdump b/jackify/engine/createdump deleted file mode 100755 index bf5252d..0000000 Binary files a/jackify/engine/createdump and /dev/null differ diff --git a/jackify/engine/cs/System.CommandLine.resources.dll b/jackify/engine/cs/System.CommandLine.resources.dll deleted file mode 100755 index 77a35a6..0000000 Binary files a/jackify/engine/cs/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/engine/de/System.CommandLine.resources.dll b/jackify/engine/de/System.CommandLine.resources.dll deleted file mode 100755 index 61ab752..0000000 Binary files a/jackify/engine/de/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/engine/es/System.CommandLine.resources.dll b/jackify/engine/es/System.CommandLine.resources.dll deleted file mode 100755 index cdd0618..0000000 Binary files a/jackify/engine/es/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/engine/fr/System.CommandLine.resources.dll b/jackify/engine/fr/System.CommandLine.resources.dll deleted file mode 100755 index 5839cd6..0000000 Binary files a/jackify/engine/fr/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/engine/it/System.CommandLine.resources.dll b/jackify/engine/it/System.CommandLine.resources.dll deleted file mode 100755 index bdca685..0000000 Binary files a/jackify/engine/it/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/engine/ja/System.CommandLine.resources.dll b/jackify/engine/ja/System.CommandLine.resources.dll deleted file mode 100755 index be146d9..0000000 Binary files a/jackify/engine/ja/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/engine/jackify-engine b/jackify/engine/jackify-engine deleted file mode 100755 index d9e1647..0000000 Binary files a/jackify/engine/jackify-engine and /dev/null differ diff --git a/jackify/engine/jackify-engine.deps.json b/jackify/engine/jackify-engine.deps.json deleted file mode 100644 index 436f9a0..0000000 --- a/jackify/engine/jackify-engine.deps.json +++ /dev/null @@ -1,3277 +0,0 @@ -{ - "runtimeTarget": { - "name": ".NETCoreApp,Version=v8.0/linux-x64", - "signature": "" - }, - "compilationOptions": {}, - "targets": { - ".NETCoreApp,Version=v8.0": {}, - ".NETCoreApp,Version=v8.0/linux-x64": { - "jackify-engine/0.5.4": { - "dependencies": { - "Markdig": "0.40.0", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Hosting": "9.0.1", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "NLog": "5.3.4", - "NLog.Extensions.Logging": "5.3.15", - "Newtonsoft.Json": "13.0.3", - "SixLabors.ImageSharp": "3.1.6", - "System.CommandLine": "2.0.0-beta4.22272.1", - "System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1", - "Wabbajack.CLI.Builder": "0.5.4", - "Wabbajack.Downloaders.Bethesda": "0.5.4", - "Wabbajack.Downloaders.Dispatcher": "0.5.4", - "Wabbajack.Hashing.xxHash64": "0.5.4", - "Wabbajack.Networking.Discord": "0.5.4", - "Wabbajack.Networking.GitHub": "0.5.4", - "Wabbajack.Paths.IO": "0.5.4", - "Wabbajack.Server.Lib": "0.5.4", - "Wabbajack.Services.OSIntegrated": "0.5.4", - "Wabbajack.VFS": "0.5.4", - "MegaApiClient": "1.0.0.0", - "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.25" - }, - "runtime": { - "jackify-engine.dll": {} - } - }, - "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64/8.0.25": { - "runtime": { - "Microsoft.CSharp.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "Microsoft.VisualBasic.Core.dll": { - "assemblyVersion": "13.0.0.0", - "fileVersion": "13.0.2526.11203" - }, - "Microsoft.VisualBasic.dll": { - "assemblyVersion": "10.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "Microsoft.Win32.Primitives.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "Microsoft.Win32.Registry.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.AppContext.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Buffers.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Collections.Concurrent.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Collections.Immutable.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Collections.NonGeneric.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Collections.Specialized.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Collections.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.ComponentModel.Annotations.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.ComponentModel.DataAnnotations.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.ComponentModel.EventBasedAsync.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.ComponentModel.Primitives.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.ComponentModel.TypeConverter.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.ComponentModel.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Configuration.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Console.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Core.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Data.Common.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Data.DataSetExtensions.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Data.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Diagnostics.Contracts.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Diagnostics.Debug.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Diagnostics.FileVersionInfo.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Diagnostics.Process.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Diagnostics.StackTrace.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Diagnostics.TextWriterTraceListener.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Diagnostics.Tools.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Diagnostics.TraceSource.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Diagnostics.Tracing.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Drawing.Primitives.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Drawing.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Dynamic.Runtime.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Formats.Asn1.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Formats.Tar.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Globalization.Calendars.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Globalization.Extensions.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Globalization.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.Compression.Brotli.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.Compression.FileSystem.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.Compression.ZipFile.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.Compression.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.FileSystem.AccessControl.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.FileSystem.DriveInfo.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.FileSystem.Primitives.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.FileSystem.Watcher.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.FileSystem.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.IsolatedStorage.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.MemoryMappedFiles.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.Pipes.AccessControl.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.Pipes.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.UnmanagedMemoryStream.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.IO.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Linq.Expressions.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Linq.Parallel.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Linq.Queryable.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Linq.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Memory.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.Http.Json.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.Http.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.HttpListener.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.Mail.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.NameResolution.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.NetworkInformation.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.Ping.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.Primitives.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.Quic.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.Requests.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.Security.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.ServicePoint.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.Sockets.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.WebClient.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.WebHeaderCollection.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.WebProxy.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.WebSockets.Client.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.WebSockets.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Net.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Numerics.Vectors.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Numerics.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.ObjectModel.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Private.CoreLib.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Private.DataContractSerialization.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Private.Uri.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Private.Xml.Linq.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Private.Xml.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Reflection.DispatchProxy.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Reflection.Emit.ILGeneration.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Reflection.Emit.Lightweight.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Reflection.Emit.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Reflection.Extensions.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Reflection.Metadata.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Reflection.Primitives.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Reflection.TypeExtensions.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Reflection.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Resources.Reader.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Resources.ResourceManager.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Resources.Writer.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.CompilerServices.Unsafe.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.CompilerServices.VisualC.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.Extensions.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.Handles.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.InteropServices.JavaScript.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.InteropServices.RuntimeInformation.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.InteropServices.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.Intrinsics.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.Loader.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.Numerics.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.Serialization.Formatters.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.Serialization.Json.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.Serialization.Primitives.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.Serialization.Xml.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.Serialization.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Runtime.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.AccessControl.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.Claims.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.Cryptography.Algorithms.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.Cryptography.Cng.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.Cryptography.Csp.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.Cryptography.Encoding.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.Cryptography.OpenSsl.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.Cryptography.Primitives.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.Cryptography.X509Certificates.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.Cryptography.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.Principal.Windows.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.Principal.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.SecureString.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Security.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.ServiceModel.Web.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.ServiceProcess.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Text.Encoding.CodePages.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Text.Encoding.Extensions.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Text.Encoding.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Text.RegularExpressions.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Threading.Channels.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Threading.Overlapped.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Threading.Tasks.Dataflow.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Threading.Tasks.Extensions.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Threading.Tasks.Parallel.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Threading.Tasks.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Threading.Thread.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Threading.ThreadPool.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Threading.Timer.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Threading.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Transactions.Local.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Transactions.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.ValueTuple.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Web.HttpUtility.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Web.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Windows.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Xml.Linq.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Xml.ReaderWriter.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Xml.Serialization.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Xml.XDocument.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Xml.XPath.XDocument.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Xml.XPath.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Xml.XmlDocument.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Xml.XmlSerializer.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.Xml.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "System.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "WindowsBase.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "mscorlib.dll": { - "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2526.11203" - }, - "netstandard.dll": { - "assemblyVersion": "2.1.0.0", - "fileVersion": "8.0.2526.11203" - } - }, - "native": { - "createdump": { - "fileVersion": "0.0.0.0" - }, - "libSystem.Globalization.Native.so": { - "fileVersion": "0.0.0.0" - }, - "libSystem.IO.Compression.Native.so": { - "fileVersion": "0.0.0.0" - }, - "libSystem.Native.so": { - "fileVersion": "0.0.0.0" - }, - "libSystem.Net.Security.Native.so": { - "fileVersion": "0.0.0.0" - }, - "libSystem.Security.Cryptography.Native.OpenSsl.so": { - "fileVersion": "0.0.0.0" - }, - "libclrgc.so": { - "fileVersion": "0.0.0.0" - }, - "libclrjit.so": { - "fileVersion": "0.0.0.0" - }, - "libcoreclr.so": { - "fileVersion": "0.0.0.0" - }, - "libcoreclrtraceptprovider.so": { - "fileVersion": "0.0.0.0" - }, - "libhostfxr.so": { - "fileVersion": "0.0.0.0" - }, - "libhostpolicy.so": { - "fileVersion": "0.0.0.0" - }, - "libmscordaccore.so": { - "fileVersion": "0.0.0.0" - }, - "libmscordbi.so": { - "fileVersion": "0.0.0.0" - } - } - }, - "BCnEncoder.Net/2.1.0": { - "dependencies": { - "Microsoft.Toolkit.HighPerformance": "7.1.2" - }, - "runtime": { - "lib/netstandard2.1/BCnEncoder.dll": { - "assemblyVersion": "2.1.0.0", - "fileVersion": "2.1.0.0" - } - } - }, - "BCnEncoder.Net.ImageSharp/1.1.1": { - "dependencies": { - "BCnEncoder.Net": "2.1.0", - "Microsoft.Toolkit.HighPerformance": "7.1.2", - "SixLabors.ImageSharp": "3.1.6" - }, - "runtime": { - "lib/netstandard2.1/BCnEncoder.NET.ImageSharp.dll": { - "assemblyVersion": "1.1.1.0", - "fileVersion": "1.1.1.0" - } - } - }, - "Crc32.NET/1.2.0": { - "dependencies": { - "NETStandard.Library": "2.0.0" - }, - "runtime": { - "lib/netstandard2.0/Crc32.NET.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.2.0.5" - } - } - }, - "DeviceId/6.8.0": { - "runtime": { - "lib/net8.0/DeviceId.dll": { - "assemblyVersion": "6.8.0.0", - "fileVersion": "6.8.0.0" - } - } - }, - "F23.StringSimilarity/6.0.0": { - "dependencies": { - "System.Memory": "4.5.4" - }, - "runtime": { - "lib/netstandard2.0/F23.StringSimilarity.dll": { - "assemblyVersion": "6.0.0.0", - "fileVersion": "6.0.0.0" - } - } - }, - "FluentFTP/52.0.0": { - "runtime": { - "lib/net7.0/FluentFTP.dll": { - "assemblyVersion": "52.0.0.0", - "fileVersion": "52.0.0.0" - } - } - }, - "FluentResults/3.15.2": { - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "System.Threading.Tasks.Extensions": "4.5.4" - }, - "runtime": { - "lib/netstandard2.1/FluentResults.dll": { - "assemblyVersion": "3.15.2.0", - "fileVersion": "3.15.2.0" - } - } - }, - "GameFinder.Common/4.5.0": { - "dependencies": { - "FluentResults": "3.15.2", - "NexusMods.Paths": "0.10.0", - "OneOf": "3.0.271" - }, - "runtime": { - "lib/net8.0/GameFinder.Common.dll": { - "assemblyVersion": "4.5.0.0", - "fileVersion": "4.5.0.0" - } - } - }, - "GameFinder.RegistryUtils/4.5.0": { - "dependencies": { - "Microsoft.Win32.Registry": "5.0.0" - }, - "runtime": { - "lib/net8.0/GameFinder.RegistryUtils.dll": { - "assemblyVersion": "4.5.0.0", - "fileVersion": "4.5.0.0" - } - } - }, - "GameFinder.StoreHandlers.EADesktop/4.5.0": { - "dependencies": { - "GameFinder.Common": "4.5.0", - "SHA3.Net": "2.0.0", - "System.Management": "8.0.0" - }, - "runtime": { - "lib/net8.0/GameFinder.StoreHandlers.EADesktop.dll": { - "assemblyVersion": "4.5.0.0", - "fileVersion": "4.5.0.0" - } - } - }, - "GameFinder.StoreHandlers.EGS/4.5.0": { - "dependencies": { - "GameFinder.Common": "4.5.0", - "GameFinder.RegistryUtils": "4.5.0" - }, - "runtime": { - "lib/net8.0/GameFinder.StoreHandlers.EGS.dll": { - "assemblyVersion": "4.5.0.0", - "fileVersion": "4.5.0.0" - } - } - }, - "GameFinder.StoreHandlers.GOG/4.5.0": { - "dependencies": { - "GameFinder.Common": "4.5.0", - "GameFinder.RegistryUtils": "4.5.0" - }, - "runtime": { - "lib/net8.0/GameFinder.StoreHandlers.GOG.dll": { - "assemblyVersion": "4.5.0.0", - "fileVersion": "4.5.0.0" - } - } - }, - "GameFinder.StoreHandlers.Origin/4.5.0": { - "dependencies": { - "GameFinder.Common": "4.5.0", - "GameFinder.RegistryUtils": "4.5.0" - }, - "runtime": { - "lib/net8.0/GameFinder.StoreHandlers.Origin.dll": { - "assemblyVersion": "4.5.0.0", - "fileVersion": "4.5.0.0" - } - } - }, - "GameFinder.StoreHandlers.Steam/4.5.0": { - "dependencies": { - "GameFinder.Common": "4.5.0", - "GameFinder.RegistryUtils": "4.5.0", - "GameFinder.Wine": "4.5.0", - "ValveKeyValue": "0.10.0.360" - }, - "runtime": { - "lib/net8.0/GameFinder.StoreHandlers.Steam.dll": { - "assemblyVersion": "4.5.0.0", - "fileVersion": "4.5.0.0" - } - } - }, - "GameFinder.Wine/4.5.0": { - "dependencies": { - "GameFinder.Common": "4.5.0", - "GameFinder.RegistryUtils": "4.5.0" - }, - "runtime": { - "lib/net8.0/GameFinder.Wine.dll": { - "assemblyVersion": "4.5.0.0", - "fileVersion": "4.5.0.0" - } - } - }, - "HtmlAgilityPack/1.11.72": { - "runtime": { - "lib/netstandard2.0/HtmlAgilityPack.dll": { - "assemblyVersion": "1.11.72.0", - "fileVersion": "1.11.72.0" - } - } - }, - "ini-parser-netstandard/2.5.2": { - "runtime": { - "lib/netstandard2.0/INIFileParser.dll": { - "assemblyVersion": "2.5.2.0", - "fileVersion": "2.5.2.0" - } - } - }, - "K4os.Compression.LZ4/1.3.8": { - "runtime": { - "lib/net6.0/K4os.Compression.LZ4.dll": { - "assemblyVersion": "1.3.8.0", - "fileVersion": "1.3.8.0" - } - } - }, - "K4os.Compression.LZ4.Streams/1.3.8": { - "dependencies": { - "K4os.Compression.LZ4": "1.3.8", - "K4os.Hash.xxHash": "1.0.8", - "System.IO.Pipelines": "9.0.1" - }, - "runtime": { - "lib/net6.0/K4os.Compression.LZ4.Streams.dll": { - "assemblyVersion": "1.3.8.0", - "fileVersion": "1.3.8.0" - } - } - }, - "K4os.Hash.xxHash/1.0.8": { - "runtime": { - "lib/net6.0/K4os.Hash.xxHash.dll": { - "assemblyVersion": "1.0.8.0", - "fileVersion": "1.0.8.0" - } - } - }, - "LibAES-CTR/1.1.0": { - "runtime": { - "lib/net8.0/AES-CTR-Netstandard.dll": { - "assemblyVersion": "1.1.0.0", - "fileVersion": "1.1.0.0" - } - } - }, - "Markdig/0.40.0": { - "runtime": { - "lib/net8.0/Markdig.dll": { - "assemblyVersion": "0.40.0.0", - "fileVersion": "0.40.0.0" - } - } - }, - "Microsoft.AspNetCore.Http.Abstractions/2.3.0": { - "dependencies": { - "Microsoft.AspNetCore.Http.Features": "2.3.0", - "System.Text.Encodings.Web": "9.0.1" - }, - "runtime": { - "lib/netstandard2.0/Microsoft.AspNetCore.Http.Abstractions.dll": { - "assemblyVersion": "2.3.0.0", - "fileVersion": "2.3.0.25014" - } - } - }, - "Microsoft.AspNetCore.Http.Extensions/2.3.0": { - "dependencies": { - "Microsoft.AspNetCore.Http.Abstractions": "2.3.0", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Net.Http.Headers": "2.3.0", - "System.Buffers": "4.6.0" - }, - "runtime": { - "lib/netstandard2.0/Microsoft.AspNetCore.Http.Extensions.dll": { - "assemblyVersion": "2.3.0.0", - "fileVersion": "2.3.0.25014" - } - } - }, - "Microsoft.AspNetCore.Http.Features/2.3.0": { - "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" - }, - "runtime": { - "lib/netstandard2.0/Microsoft.AspNetCore.Http.Features.dll": { - "assemblyVersion": "2.3.0.0", - "fileVersion": "2.3.0.25014" - } - } - }, - "Microsoft.CSharp/4.7.0": {}, - "Microsoft.Extensions.Configuration/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Configuration.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Configuration.Abstractions/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Configuration.Abstractions.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Configuration.Binder/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Configuration.Binder.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Configuration.CommandLine/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Configuration.CommandLine.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Configuration.EnvironmentVariables/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Configuration.EnvironmentVariables.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Configuration.FileExtensions/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Configuration.FileExtensions.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Configuration.Json/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "System.Text.Json": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Configuration.Json.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Configuration.UserSecrets/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Configuration.UserSecrets.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.DependencyInjection/9.0.1": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.DependencyInjection.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions/9.0.1": { - "runtime": { - "lib/net8.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Diagnostics/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Diagnostics.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions/9.0.1": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Diagnostics.DiagnosticSource": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Diagnostics.Abstractions.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.FileProviders.Abstractions/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.FileProviders.Abstractions.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.FileProviders.Physical/9.0.1": { - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileSystemGlobbing": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.FileProviders.Physical.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.FileSystemGlobbing/9.0.1": { - "runtime": { - "lib/net8.0/Microsoft.Extensions.FileSystemGlobbing.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Hosting/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.Configuration.CommandLine": "9.0.1", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.Configuration.UserSecrets": "9.0.1", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Configuration": "9.0.1", - "Microsoft.Extensions.Logging.Console": "9.0.1", - "Microsoft.Extensions.Logging.Debug": "9.0.1", - "Microsoft.Extensions.Logging.EventLog": "9.0.1", - "Microsoft.Extensions.Logging.EventSource": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Hosting.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Hosting.Abstractions/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Hosting.Abstractions.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Http/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Http.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Logging/9.0.1": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Logging.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Logging.Abstractions/9.0.1": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "System.Diagnostics.DiagnosticSource": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Logging.Abstractions.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Logging.Configuration/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Logging.Configuration.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Logging.Console/9.0.1": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Configuration": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Text.Json": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Logging.Console.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Logging.Debug/9.0.1": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Logging.Debug.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Logging.EventLog/9.0.1": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Diagnostics.EventLog": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Logging.EventLog.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Logging.EventSource/9.0.1": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1", - "System.Text.Json": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Logging.EventSource.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Options/9.0.1": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Options.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions/9.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Extensions.Primitives/9.0.1": { - "runtime": { - "lib/net8.0/Microsoft.Extensions.Primitives.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "Microsoft.Net.Http.Headers/2.3.0": { - "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1", - "System.Buffers": "4.6.0" - }, - "runtime": { - "lib/netstandard2.0/Microsoft.Net.Http.Headers.dll": { - "assemblyVersion": "2.3.0.0", - "fileVersion": "2.3.0.25014" - } - } - }, - "Microsoft.NETCore.Platforms/5.0.0": {}, - "Microsoft.Toolkit.HighPerformance/7.1.2": { - "runtime": { - "lib/net5.0/Microsoft.Toolkit.HighPerformance.dll": { - "assemblyVersion": "7.1.0.0", - "fileVersion": "7.1.2.1" - } - } - }, - "Microsoft.Win32.Registry/5.0.0": { - "dependencies": { - "System.Security.AccessControl": "5.0.0", - "System.Security.Principal.Windows": "5.0.0" - } - }, - "Microsoft.Win32.SystemEvents/5.0.0": { - "dependencies": { - "Microsoft.NETCore.Platforms": "5.0.0" - }, - "runtime": { - "lib/netstandard2.0/Microsoft.Win32.SystemEvents.dll": { - "assemblyVersion": "5.0.0.0", - "fileVersion": "5.0.20.51904" - } - } - }, - "NETStandard.Library/2.0.0": { - "dependencies": { - "Microsoft.NETCore.Platforms": "5.0.0" - } - }, - "Nettle/3.0.0": { - "dependencies": { - "Microsoft.CSharp": "4.7.0" - }, - "runtime": { - "lib/net7.0/Nettle.dll": { - "assemblyVersion": "3.0.0.0", - "fileVersion": "3.0.0.0" - } - } - }, - "Newtonsoft.Json/13.0.3": { - "runtime": { - "lib/net6.0/Newtonsoft.Json.dll": { - "assemblyVersion": "13.0.0.0", - "fileVersion": "13.0.3.27908" - } - } - }, - "NexusMods.Paths/0.10.0": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Reloaded.Memory": "9.4.1" - }, - "runtime": { - "lib/net7.0/NexusMods.Paths.dll": { - "assemblyVersion": "0.10.0.0", - "fileVersion": "0.10.0.0" - } - } - }, - "NLog/5.3.4": { - "runtime": { - "lib/netstandard2.0/NLog.dll": { - "assemblyVersion": "5.0.0.0", - "fileVersion": "5.3.4.2778" - } - } - }, - "NLog.Extensions.Logging/5.3.15": { - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "NLog": "5.3.4" - }, - "runtime": { - "lib/net8.0/NLog.Extensions.Logging.dll": { - "assemblyVersion": "5.0.0.0", - "fileVersion": "5.3.15.614" - } - } - }, - "Octokit/14.0.0": { - "runtime": { - "lib/netstandard2.0/Octokit.dll": { - "assemblyVersion": "14.0.0.0", - "fileVersion": "14.0.0.0" - } - } - }, - "Octopus.Octodiff/2.0.548": { - "runtime": { - "lib/netstandard2.0/Octodiff.dll": { - "assemblyVersion": "2.0.548.0", - "fileVersion": "2.0.548.0" - } - } - }, - "OMODFramework/3.0.1": { - "dependencies": { - "Crc32.NET": "1.2.0", - "K4os.Compression.LZ4": "1.3.8", - "K4os.Compression.LZ4.Streams": "1.3.8", - "SharpCompress": "0.28.1", - "System.Drawing.Common": "5.0.2", - "System.Text.Encoding.CodePages": "5.0.0" - }, - "runtime": { - "lib/net5.0/OMODFramework.dll": { - "assemblyVersion": "3.0.1.0", - "fileVersion": "3.0.1.0" - } - } - }, - "OneOf/3.0.271": { - "runtime": { - "lib/netstandard2.0/OneOf.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - }, - "Reloaded.Memory/9.4.1": { - "runtime": { - "lib/net8.0/Reloaded.Memory.dll": { - "assemblyVersion": "9.4.1.0", - "fileVersion": "9.4.1.0" - } - } - }, - "SHA3.Net/2.0.0": { - "runtime": { - "lib/net6.0/SHA3.Net.dll": { - "assemblyVersion": "2.0.0.0", - "fileVersion": "2.0.0.0" - } - } - }, - "SharpCompress/0.28.1": { - "runtime": { - "lib/net5.0/SharpCompress.dll": { - "assemblyVersion": "0.28.1.0", - "fileVersion": "0.28.1.0" - } - } - }, - "SharpZipLib/1.4.2": { - "runtime": { - "lib/net6.0/ICSharpCode.SharpZipLib.dll": { - "assemblyVersion": "1.4.2.13", - "fileVersion": "1.4.2.13" - } - } - }, - "Shipwreck.Phash/0.5.0": { - "runtime": { - "lib/netcoreapp3.0/Shipwreck.Phash.dll": { - "assemblyVersion": "0.5.0.0", - "fileVersion": "0.5.0.0" - } - } - }, - "shortid/4.0.0": { - "runtime": { - "lib/netstandard1.0/shortid.dll": { - "assemblyVersion": "3.0.1.0", - "fileVersion": "3.0.1.0" - } - } - }, - "SixLabors.ImageSharp/3.1.6": { - "runtime": { - "lib/net6.0/SixLabors.ImageSharp.dll": { - "assemblyVersion": "3.0.0.0", - "fileVersion": "3.1.6.0" - } - } - }, - "Stub.System.Data.SQLite.Core.NetStandard/1.0.119": { - "runtime": { - "lib/netstandard2.1/System.Data.SQLite.dll": { - "assemblyVersion": "1.0.119.0", - "fileVersion": "1.0.119.0" - } - }, - "native": { - "runtimes/linux-x64/native/SQLite.Interop.dll": { - "fileVersion": "0.0.0.0" - } - } - }, - "System.Buffers/4.6.0": {}, - "System.CodeDom/8.0.0": { - "runtime": { - "lib/net8.0/System.CodeDom.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.23.53103" - } - } - }, - "System.CommandLine/2.0.0-beta4.22272.1": { - "runtime": { - "lib/net6.0/System.CommandLine.dll": { - "assemblyVersion": "2.0.0.0", - "fileVersion": "2.0.22.27201" - } - }, - "resources": { - "lib/net6.0/cs/System.CommandLine.resources.dll": { - "locale": "cs" - }, - "lib/net6.0/de/System.CommandLine.resources.dll": { - "locale": "de" - }, - "lib/net6.0/es/System.CommandLine.resources.dll": { - "locale": "es" - }, - "lib/net6.0/fr/System.CommandLine.resources.dll": { - "locale": "fr" - }, - "lib/net6.0/it/System.CommandLine.resources.dll": { - "locale": "it" - }, - "lib/net6.0/ja/System.CommandLine.resources.dll": { - "locale": "ja" - }, - "lib/net6.0/ko/System.CommandLine.resources.dll": { - "locale": "ko" - }, - "lib/net6.0/pl/System.CommandLine.resources.dll": { - "locale": "pl" - }, - "lib/net6.0/pt-BR/System.CommandLine.resources.dll": { - "locale": "pt-BR" - }, - "lib/net6.0/ru/System.CommandLine.resources.dll": { - "locale": "ru" - }, - "lib/net6.0/tr/System.CommandLine.resources.dll": { - "locale": "tr" - }, - "lib/net6.0/zh-Hans/System.CommandLine.resources.dll": { - "locale": "zh-Hans" - }, - "lib/net6.0/zh-Hant/System.CommandLine.resources.dll": { - "locale": "zh-Hant" - } - } - }, - "System.CommandLine.NamingConventionBinder/2.0.0-beta4.22272.1": { - "dependencies": { - "System.CommandLine": "2.0.0-beta4.22272.1" - }, - "runtime": { - "lib/netstandard2.0/System.CommandLine.NamingConventionBinder.dll": { - "assemblyVersion": "2.0.0.0", - "fileVersion": "2.0.22.27201" - } - } - }, - "System.Data.SQLite.Core/1.0.119": { - "dependencies": { - "Stub.System.Data.SQLite.Core.NetStandard": "1.0.119" - } - }, - "System.Diagnostics.DiagnosticSource/9.0.1": { - "runtime": { - "lib/net8.0/System.Diagnostics.DiagnosticSource.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "System.Diagnostics.EventLog/9.0.1": { - "runtime": { - "lib/net8.0/System.Diagnostics.EventLog.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "System.Drawing.Common/5.0.2": { - "dependencies": { - "Microsoft.Win32.SystemEvents": "5.0.0" - }, - "runtime": { - "runtimes/unix/lib/netcoreapp3.0/System.Drawing.Common.dll": { - "assemblyVersion": "5.0.0.2", - "fileVersion": "5.0.421.11614" - } - } - }, - "System.IO.Pipelines/9.0.1": { - "runtime": { - "lib/net8.0/System.IO.Pipelines.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "System.Management/8.0.0": { - "dependencies": { - "System.CodeDom": "8.0.0" - }, - "runtime": { - "lib/net8.0/System.Management.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.23.53103" - } - } - }, - "System.Memory/4.5.4": {}, - "System.Reactive/6.0.1": { - "runtime": { - "lib/net6.0/System.Reactive.dll": { - "assemblyVersion": "6.0.0.0", - "fileVersion": "6.0.1.7420" - } - } - }, - "System.Security.AccessControl/5.0.0": { - "dependencies": { - "Microsoft.NETCore.Platforms": "5.0.0", - "System.Security.Principal.Windows": "5.0.0" - } - }, - "System.Security.Principal.Windows/5.0.0": {}, - "System.Text.Encoding.CodePages/5.0.0": { - "dependencies": { - "Microsoft.NETCore.Platforms": "5.0.0" - } - }, - "System.Text.Encodings.Web/9.0.1": { - "runtime": { - "lib/net8.0/System.Text.Encodings.Web.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "System.Text.Json/9.0.1": { - "dependencies": { - "System.IO.Pipelines": "9.0.1", - "System.Text.Encodings.Web": "9.0.1" - }, - "runtime": { - "lib/net8.0/System.Text.Json.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.124.61010" - } - } - }, - "System.Threading.Tasks.Extensions/4.5.4": {}, - "ValveKeyValue/0.10.0.360": { - "runtime": { - "lib/netstandard2.1/ValveKeyValue.dll": { - "assemblyVersion": "0.10.0.0", - "fileVersion": "0.10.0.360" - } - } - }, - "YamlDotNet/16.3.0": { - "runtime": { - "lib/net8.0/YamlDotNet.dll": { - "assemblyVersion": "16.0.0.0", - "fileVersion": "16.3.0.0" - } - } - }, - "Wabbajack.CLI.Builder/0.5.4": { - "dependencies": { - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Hosting": "9.0.1", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "System.CommandLine": "2.0.0-beta4.22272.1", - "System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1", - "Wabbajack.Paths": "0.5.4" - }, - "runtime": { - "Wabbajack.CLI.Builder.dll": {} - } - }, - "Wabbajack.Common/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "System.Reactive": "6.0.1", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Networking.Http": "0.5.4", - "Wabbajack.Paths.IO": "0.5.4" - }, - "runtime": { - "Wabbajack.Common.dll": {} - } - }, - "Wabbajack.Compiler/0.5.4": { - "dependencies": { - "F23.StringSimilarity": "6.0.0", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Newtonsoft.Json": "13.0.3", - "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Downloaders.Dispatcher": "0.5.4", - "Wabbajack.Installer": "0.5.4", - "Wabbajack.VFS": "0.5.4", - "ini-parser-netstandard": "2.5.2" - }, - "runtime": { - "Wabbajack.Compiler.dll": {} - } - }, - "Wabbajack.Compression.BSA/0.5.4": { - "dependencies": { - "K4os.Compression.LZ4.Streams": "1.3.8", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "SharpZipLib": "1.4.2", - "Wabbajack.Common": "0.5.4", - "Wabbajack.DTOs": "0.5.4" - }, - "runtime": { - "Wabbajack.Compression.BSA.dll": {} - } - }, - "Wabbajack.Compression.Zip/0.5.4": { - "dependencies": { - "Wabbajack.IO.Async": "0.5.4" - }, - "runtime": { - "Wabbajack.Compression.Zip.dll": {} - } - }, - "Wabbajack.Configuration/0.5.4": { - "runtime": { - "Wabbajack.Configuration.dll": {} - } - }, - "Wabbajack.Downloaders.Bethesda/0.5.4": { - "dependencies": { - "LibAES-CTR": "1.1.0", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "SharpZipLib": "1.4.2", - "Wabbajack.Common": "0.5.4", - "Wabbajack.Downloaders.Interfaces": "0.5.4", - "Wabbajack.Networking.BethesdaNet": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.Bethesda.dll": {} - } - }, - "Wabbajack.Downloaders.Dispatcher/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Newtonsoft.Json": "13.0.3", - "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Downloaders.Bethesda": "0.5.4", - "Wabbajack.Downloaders.GameFile": "0.5.4", - "Wabbajack.Downloaders.GoogleDrive": "0.5.4", - "Wabbajack.Downloaders.Http": "0.5.4", - "Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.5.4", - "Wabbajack.Downloaders.Interfaces": "0.5.4", - "Wabbajack.Downloaders.Manual": "0.5.4", - "Wabbajack.Downloaders.MediaFire": "0.5.4", - "Wabbajack.Downloaders.Mega": "0.5.4", - "Wabbajack.Downloaders.ModDB": "0.5.4", - "Wabbajack.Downloaders.Nexus": "0.5.4", - "Wabbajack.Downloaders.VerificationCache": "0.5.4", - "Wabbajack.Downloaders.WabbajackCDN": "0.5.4", - "Wabbajack.Networking.WabbajackClientApi": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.Dispatcher.dll": {} - } - }, - "Wabbajack.Downloaders.GameFile/0.5.4": { - "dependencies": { - "GameFinder.StoreHandlers.EADesktop": "4.5.0", - "GameFinder.StoreHandlers.EGS": "4.5.0", - "GameFinder.StoreHandlers.GOG": "4.5.0", - "GameFinder.StoreHandlers.Origin": "4.5.0", - "GameFinder.StoreHandlers.Steam": "4.5.0", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Downloaders.Interfaces": "0.5.4", - "Wabbajack.VFS": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.GameFile.dll": {} - } - }, - "Wabbajack.Downloaders.GoogleDrive/0.5.4": { - "dependencies": { - "HtmlAgilityPack": "1.11.72", - "Microsoft.AspNetCore.Http.Extensions": "2.3.0", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.5.4", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Downloaders.Interfaces": "0.5.4", - "Wabbajack.Networking.Http": "0.5.4", - "Wabbajack.Networking.Http.Interfaces": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.GoogleDrive.dll": {} - } - }, - "Wabbajack.Downloaders.Http/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.5.4", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Downloaders.Interfaces": "0.5.4", - "Wabbajack.Networking.BethesdaNet": "0.5.4", - "Wabbajack.Networking.Http.Interfaces": "0.5.4", - "Wabbajack.Paths.IO": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.Http.dll": {} - } - }, - "Wabbajack.Downloaders.Interfaces/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.Compression.Zip": "0.5.4", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Paths.IO": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.Interfaces.dll": {} - } - }, - "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.5.4": { - "dependencies": { - "F23.StringSimilarity": "6.0.0", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.5.4", - "Wabbajack.Downloaders.Interfaces": "0.5.4", - "Wabbajack.Networking.Http": "0.5.4", - "Wabbajack.Networking.Http.Interfaces": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {} - } - }, - "Wabbajack.Downloaders.Manual/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.5.4", - "Wabbajack.Downloaders.Interfaces": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.Manual.dll": {} - } - }, - "Wabbajack.Downloaders.MediaFire/0.5.4": { - "dependencies": { - "HtmlAgilityPack": "1.11.72", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.5.4", - "Wabbajack.Downloaders.Interfaces": "0.5.4", - "Wabbajack.Networking.Http.Interfaces": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.MediaFire.dll": {} - } - }, - "Wabbajack.Downloaders.Mega/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Newtonsoft.Json": "13.0.3", - "Wabbajack.Common": "0.5.4", - "Wabbajack.Downloaders.Interfaces": "0.5.4", - "Wabbajack.Paths.IO": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.Mega.dll": {} - } - }, - "Wabbajack.Downloaders.ModDB/0.5.4": { - "dependencies": { - "HtmlAgilityPack": "1.11.72", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Newtonsoft.Json": "13.0.3", - "Wabbajack.Common": "0.5.4", - "Wabbajack.Downloaders.Interfaces": "0.5.4", - "Wabbajack.Networking.Http": "0.5.4", - "Wabbajack.Networking.Http.Interfaces": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.ModDB.dll": {} - } - }, - "Wabbajack.Downloaders.Nexus/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Downloaders.Interfaces": "0.5.4", - "Wabbajack.Hashing.xxHash64": "0.5.4", - "Wabbajack.Networking.Http": "0.5.4", - "Wabbajack.Networking.Http.Interfaces": "0.5.4", - "Wabbajack.Networking.NexusApi": "0.5.4", - "Wabbajack.Paths": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.Nexus.dll": {} - } - }, - "Wabbajack.Downloaders.VerificationCache/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Stub.System.Data.SQLite.Core.NetStandard": "1.0.119", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Paths.IO": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.VerificationCache.dll": {} - } - }, - "Wabbajack.Downloaders.WabbajackCDN/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Toolkit.HighPerformance": "7.1.2", - "Wabbajack.Common": "0.5.4", - "Wabbajack.Downloaders.Interfaces": "0.5.4", - "Wabbajack.Networking.Http": "0.5.4", - "Wabbajack.RateLimiter": "0.5.4" - }, - "runtime": { - "Wabbajack.Downloaders.WabbajackCDN.dll": {} - } - }, - "Wabbajack.DTOs/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.Hashing.xxHash64": "0.5.4", - "Wabbajack.Paths": "0.5.4" - }, - "runtime": { - "Wabbajack.DTOs.dll": {} - } - }, - "Wabbajack.FileExtractor/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "OMODFramework": "3.0.1", - "Wabbajack.Common": "0.5.4", - "Wabbajack.Compression.BSA": "0.5.4", - "Wabbajack.Hashing.PHash": "0.5.4", - "Wabbajack.Paths": "0.5.4" - }, - "runtime": { - "Wabbajack.FileExtractor.dll": {} - } - }, - "Wabbajack.Hashing.PHash/0.5.4": { - "dependencies": { - "BCnEncoder.Net.ImageSharp": "1.1.1", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Shipwreck.Phash": "0.5.0", - "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Common": "0.5.4", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Paths": "0.5.4", - "Wabbajack.Paths.IO": "0.5.4" - }, - "runtime": { - "Wabbajack.Hashing.PHash.dll": {} - } - }, - "Wabbajack.Hashing.xxHash64/0.5.4": { - "dependencies": { - "Wabbajack.Paths": "0.5.4", - "Wabbajack.RateLimiter": "0.5.4" - }, - "runtime": { - "Wabbajack.Hashing.xxHash64.dll": {} - } - }, - "Wabbajack.Installer/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Newtonsoft.Json": "13.0.3", - "Octopus.Octodiff": "2.0.548", - "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Downloaders.Dispatcher": "0.5.4", - "Wabbajack.Downloaders.GameFile": "0.5.4", - "Wabbajack.FileExtractor": "0.5.4", - "Wabbajack.Networking.NexusApi": "0.5.4", - "Wabbajack.Networking.WabbajackClientApi": "0.5.4", - "Wabbajack.Paths": "0.5.4", - "Wabbajack.Paths.IO": "0.5.4", - "Wabbajack.VFS": "0.5.4", - "ini-parser-netstandard": "2.5.2" - }, - "runtime": { - "Wabbajack.Installer.dll": {} - } - }, - "Wabbajack.IO.Async/0.5.4": { - "runtime": { - "Wabbajack.IO.Async.dll": {} - } - }, - "Wabbajack.Networking.BethesdaNet/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Networking.Http": "0.5.4", - "Wabbajack.Networking.Http.Interfaces": "0.5.4" - }, - "runtime": { - "Wabbajack.Networking.BethesdaNet.dll": {} - } - }, - "Wabbajack.Networking.Discord/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Networking.Http.Interfaces": "0.5.4" - }, - "runtime": { - "Wabbajack.Networking.Discord.dll": {} - } - }, - "Wabbajack.Networking.GitHub/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Octokit": "14.0.0", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Networking.Http.Interfaces": "0.5.4" - }, - "runtime": { - "Wabbajack.Networking.GitHub.dll": {} - } - }, - "Wabbajack.Networking.Http/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Http": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Wabbajack.Configuration": "0.5.4", - "Wabbajack.Downloaders.Interfaces": "0.5.4", - "Wabbajack.Hashing.xxHash64": "0.5.4", - "Wabbajack.Networking.Http.Interfaces": "0.5.4", - "Wabbajack.Paths": "0.5.4", - "Wabbajack.Paths.IO": "0.5.4" - }, - "runtime": { - "Wabbajack.Networking.Http.dll": {} - } - }, - "Wabbajack.Networking.Http.Interfaces/0.5.4": { - "dependencies": { - "Wabbajack.Hashing.xxHash64": "0.5.4" - }, - "runtime": { - "Wabbajack.Networking.Http.Interfaces.dll": {} - } - }, - "Wabbajack.Networking.NexusApi/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Networking.Http": "0.5.4", - "Wabbajack.Networking.Http.Interfaces": "0.5.4", - "Wabbajack.Networking.WabbajackClientApi": "0.5.4" - }, - "runtime": { - "Wabbajack.Networking.NexusApi.dll": {} - } - }, - "Wabbajack.Networking.WabbajackClientApi/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Octokit": "14.0.0", - "Wabbajack.Common": "0.5.4", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Paths.IO": "0.5.4", - "Wabbajack.VFS.Interfaces": "0.5.4", - "YamlDotNet": "16.3.0" - }, - "runtime": { - "Wabbajack.Networking.WabbajackClientApi.dll": {} - } - }, - "Wabbajack.Paths/0.5.4": { - "runtime": { - "Wabbajack.Paths.dll": {} - } - }, - "Wabbajack.Paths.IO/0.5.4": { - "dependencies": { - "Wabbajack.Paths": "0.5.4", - "shortid": "4.0.0" - }, - "runtime": { - "Wabbajack.Paths.IO.dll": {} - } - }, - "Wabbajack.RateLimiter/0.5.4": { - "runtime": { - "Wabbajack.RateLimiter.dll": {} - } - }, - "Wabbajack.Server.Lib/0.5.4": { - "dependencies": { - "FluentFTP": "52.0.0", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Nettle": "3.0.0", - "Newtonsoft.Json": "13.0.3", - "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Common": "0.5.4", - "Wabbajack.Networking.Http.Interfaces": "0.5.4", - "Wabbajack.Services.OSIntegrated": "0.5.4" - }, - "runtime": { - "Wabbajack.Server.Lib.dll": {} - } - }, - "Wabbajack.Services.OSIntegrated/0.5.4": { - "dependencies": { - "DeviceId": "6.8.0", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Newtonsoft.Json": "13.0.3", - "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Compiler": "0.5.4", - "Wabbajack.Downloaders.Dispatcher": "0.5.4", - "Wabbajack.Installer": "0.5.4", - "Wabbajack.Networking.BethesdaNet": "0.5.4", - "Wabbajack.Networking.Discord": "0.5.4", - "Wabbajack.VFS": "0.5.4" - }, - "runtime": { - "Wabbajack.Services.OSIntegrated.dll": {} - } - }, - "Wabbajack.VFS/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "SixLabors.ImageSharp": "3.1.6", - "System.Data.SQLite.Core": "1.0.119", - "Wabbajack.Common": "0.5.4", - "Wabbajack.FileExtractor": "0.5.4", - "Wabbajack.Hashing.PHash": "0.5.4", - "Wabbajack.Hashing.xxHash64": "0.5.4", - "Wabbajack.Paths": "0.5.4", - "Wabbajack.Paths.IO": "0.5.4", - "Wabbajack.VFS.Interfaces": "0.5.4" - }, - "runtime": { - "Wabbajack.VFS.dll": {} - } - }, - "Wabbajack.VFS.Interfaces/0.5.4": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.5.4", - "Wabbajack.Hashing.xxHash64": "0.5.4", - "Wabbajack.Paths": "0.5.4" - }, - "runtime": { - "Wabbajack.VFS.Interfaces.dll": {} - } - }, - "MegaApiClient/1.0.0.0": { - "runtime": { - "MegaApiClient.dll": { - "assemblyVersion": "1.0.0.0", - "fileVersion": "1.0.0.0" - } - } - } - } - }, - "libraries": { - "jackify-engine/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64/8.0.25": { - "type": "runtimepack", - "serviceable": false, - "sha512": "" - }, - "BCnEncoder.Net/2.1.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-bBKYUDhpYenmp8vy5BVUHmS2LFym0NMga9V+S1eMJ+4zQLBwDw63Bs83AKOyqBKC2AqqgxXdTFA2a2V+SmGoIw==", - "path": "bcnencoder.net/2.1.0", - "hashPath": "bcnencoder.net.2.1.0.nupkg.sha512" - }, - "BCnEncoder.Net.ImageSharp/1.1.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-JL/woctP6UucxcLKUruMI+TIWTbSV7KO1RWY9/xMDuQqwo1BFXAIrShllMGHYIJ4B9bwGVGZNNbZUyM7rPLMTA==", - "path": "bcnencoder.net.imagesharp/1.1.1", - "hashPath": "bcnencoder.net.imagesharp.1.1.1.nupkg.sha512" - }, - "Crc32.NET/1.2.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-wNW/huzolu8MNKUnwCVKxjfAlCFpeI8AZVfF46iAWJ1+P6bTU1AZct7VAkDDEjgeeTJCVTkGZaD6jSd/fOiUkA==", - "path": "crc32.net/1.2.0", - "hashPath": "crc32.net.1.2.0.nupkg.sha512" - }, - "DeviceId/6.8.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-ucVESw96iKjkf4whbY52j8yAzqNk2AnLrPCgRt28Xpuo+dA2GtwXAtqTW3nfK2B3se3jg5aEoaIulHIg591stA==", - "path": "deviceid/6.8.0", - "hashPath": "deviceid.6.8.0.nupkg.sha512" - }, - "F23.StringSimilarity/6.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-kzEjznD9nAOep+E3LGIMD6mIAc+xEAJ91ds633+PxrBHs6i0ly6AI7v98B8ueTkKnYufFt/W0lytkoa+k7/d8w==", - "path": "f23.stringsimilarity/6.0.0", - "hashPath": "f23.stringsimilarity.6.0.0.nupkg.sha512" - }, - "FluentFTP/52.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-8wR9CCB++2eM4Y1b+HV6heWv5EDSOk3wiBWz4G9bNKjsEnTq4UUH9/g4RjmcmdN2mszIPclWS0cEuVMdqEuu1g==", - "path": "fluentftp/52.0.0", - "hashPath": "fluentftp.52.0.0.nupkg.sha512" - }, - "FluentResults/3.15.2": { - "type": "package", - "serviceable": true, - "sha512": "sha512-yR3PD1SWKirYNpQ/t9TjJrsp1adazMmozLDPeamvSIljLAIjS0EQyyGvoupWsbZEryqW9SHbKXVL8YaJwURk8w==", - "path": "fluentresults/3.15.2", - "hashPath": "fluentresults.3.15.2.nupkg.sha512" - }, - "GameFinder.Common/4.5.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-ybAKnLnE7Dw7KnoblLko8KlTZR3B+fKoB/og7qWHtZvpwCU1LEnQyN7hF+v6ENB1Hgph6AnK7I9TNdYRFfFyFg==", - "path": "gamefinder.common/4.5.0", - "hashPath": "gamefinder.common.4.5.0.nupkg.sha512" - }, - "GameFinder.RegistryUtils/4.5.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-6CmRpMUlRF0Dr5yjKXXUM2as79YahccPsWsnfQTQ5uoGOI7REakQwEnep7ArogyI+vZ+rxX8UwMHrizuefTqkg==", - "path": "gamefinder.registryutils/4.5.0", - "hashPath": "gamefinder.registryutils.4.5.0.nupkg.sha512" - }, - "GameFinder.StoreHandlers.EADesktop/4.5.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-Mzqva+/Jv8oBQP+fvseV5V3hkfE0kBYZvuD4W9iKOKyBeDW/Cu2MKMz/A4qefzuYnZn/yC5pt/1xc0IvTpUjDA==", - "path": "gamefinder.storehandlers.eadesktop/4.5.0", - "hashPath": "gamefinder.storehandlers.eadesktop.4.5.0.nupkg.sha512" - }, - "GameFinder.StoreHandlers.EGS/4.5.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-wpqN2mkjYzJHNTchAwJ1H2mG7+IQ0+Fhc631gL5nWOF2XQz8ld9Z8+NYl45tllXgzTohAZiduDFmJkedZ2p4VQ==", - "path": "gamefinder.storehandlers.egs/4.5.0", - "hashPath": "gamefinder.storehandlers.egs.4.5.0.nupkg.sha512" - }, - "GameFinder.StoreHandlers.GOG/4.5.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-4Du2F2HMekVDcT1beq4OoER6wIpKOAg1D/9qhkSefclEYCoBvmYPsfn20xeEm2ebv86q7UWSZFJOtQBbrv41iQ==", - "path": "gamefinder.storehandlers.gog/4.5.0", - "hashPath": "gamefinder.storehandlers.gog.4.5.0.nupkg.sha512" - }, - "GameFinder.StoreHandlers.Origin/4.5.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-H3zVo2ArEh2j0ZJCNyXLpiDajkH2GEjEMBypS/7oSY/hDuOkPOFPeNy6nKjJg2mjUT3rAgFYDwYiXLthWODQdA==", - "path": "gamefinder.storehandlers.origin/4.5.0", - "hashPath": "gamefinder.storehandlers.origin.4.5.0.nupkg.sha512" - }, - "GameFinder.StoreHandlers.Steam/4.5.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-shGE0ibk4bhdyTIxfLD1s7/M9GYgdbGDuyvQ0WnVpCibKs7kA8b09NEjRoxHuGwDbeEKJwTaly5odeBYCARWHA==", - "path": "gamefinder.storehandlers.steam/4.5.0", - "hashPath": "gamefinder.storehandlers.steam.4.5.0.nupkg.sha512" - }, - "GameFinder.Wine/4.5.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-MoWh6zwFrqI4PZD219CSYWgIe8E23ORoYdXRZKC+oLsSOUl6YpvwsT8mwl3E83czLOxE+ZgqbpuCgntKs2FBAg==", - "path": "gamefinder.wine/4.5.0", - "hashPath": "gamefinder.wine.4.5.0.nupkg.sha512" - }, - "HtmlAgilityPack/1.11.72": { - "type": "package", - "serviceable": true, - "sha512": "sha512-RNLgXxTFdIGFI+o5l8c2aJ2L5StIRn9Uv8HKR76p7QP4ZUL26wzpWUCWh08xWUdkL2kocl+Xhv6VUu0rA1npVg==", - "path": "htmlagilitypack/1.11.72", - "hashPath": "htmlagilitypack.1.11.72.nupkg.sha512" - }, - "ini-parser-netstandard/2.5.2": { - "type": "package", - "serviceable": true, - "sha512": "sha512-rTyGzgT/a+mD2HxopOGpb/yWWhu5TTPgF59XUlHjhlNv7dbt9DME/qozIR7HreVAVpNnZSqP9A8TeViK5s949g==", - "path": "ini-parser-netstandard/2.5.2", - "hashPath": "ini-parser-netstandard.2.5.2.nupkg.sha512" - }, - "K4os.Compression.LZ4/1.3.8": { - "type": "package", - "serviceable": true, - "sha512": "sha512-LhwlPa7c1zs1OV2XadMtAWdImjLIsqFJPoRcIWAadSRn0Ri1DepK65UbWLPmt4riLqx2d40xjXRk0ogpqNtK7g==", - "path": "k4os.compression.lz4/1.3.8", - "hashPath": "k4os.compression.lz4.1.3.8.nupkg.sha512" - }, - "K4os.Compression.LZ4.Streams/1.3.8": { - "type": "package", - "serviceable": true, - "sha512": "sha512-P15qr8dZAeo9GvYbUIPEYFQ0MEJ0i5iqr37wsYeRC3la2uCldOoeCa6to0CZ1taiwxIV+Mk8NGuZi+4iWivK9w==", - "path": "k4os.compression.lz4.streams/1.3.8", - "hashPath": "k4os.compression.lz4.streams.1.3.8.nupkg.sha512" - }, - "K4os.Hash.xxHash/1.0.8": { - "type": "package", - "serviceable": true, - "sha512": "sha512-Wp2F7BamQ2Q/7Hk834nV9vRQapgcr8kgv9Jvfm8J3D0IhDqZMMl+a2yxUq5ltJitvXvQfB8W6K4F4fCbw/P6YQ==", - "path": "k4os.hash.xxhash/1.0.8", - "hashPath": "k4os.hash.xxhash.1.0.8.nupkg.sha512" - }, - "LibAES-CTR/1.1.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-qyHN/VQPA+MyoEr2pzcUcF41Hxh7GCtcmeGDNIEcnZP5A41ZfCke89dPd9xrxywuy6BJd/QleHFKNeZY2QUO9A==", - "path": "libaes-ctr/1.1.0", - "hashPath": "libaes-ctr.1.1.0.nupkg.sha512" - }, - "Markdig/0.40.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-4ve14zs+gt1irldTQE3y5FLAHuzmhW7T99lAAvVipe/q2LWT/nUCO0iICb9TXGvMX6n7Z1OZroFXkdSy91rO8w==", - "path": "markdig/0.40.0", - "hashPath": "markdig.0.40.0.nupkg.sha512" - }, - "Microsoft.AspNetCore.Http.Abstractions/2.3.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-39r9PPrjA6s0blyFv5qarckjNkaHRA5B+3b53ybuGGNTXEj1/DStQJ4NWjFL6QTRQpL9zt7nDyKxZdJOlcnq+Q==", - "path": "microsoft.aspnetcore.http.abstractions/2.3.0", - "hashPath": "microsoft.aspnetcore.http.abstractions.2.3.0.nupkg.sha512" - }, - "Microsoft.AspNetCore.Http.Extensions/2.3.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-EY2u/wFF5jsYwGXXswfQWrSsFPmiXsniAlUWo3rv/MGYf99ZFsENDnZcQP6W3c/+xQmQXq0NauzQ7jyy+o1LDQ==", - "path": "microsoft.aspnetcore.http.extensions/2.3.0", - "hashPath": "microsoft.aspnetcore.http.extensions.2.3.0.nupkg.sha512" - }, - "Microsoft.AspNetCore.Http.Features/2.3.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-f10WUgcsKqrkmnz6gt8HeZ7kyKjYN30PO7cSic1lPtH7paPtnQqXPOveul/SIPI43PhRD4trttg4ywnrEmmJpA==", - "path": "microsoft.aspnetcore.http.features/2.3.0", - "hashPath": "microsoft.aspnetcore.http.features.2.3.0.nupkg.sha512" - }, - "Microsoft.CSharp/4.7.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==", - "path": "microsoft.csharp/4.7.0", - "hashPath": "microsoft.csharp.4.7.0.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", - "path": "microsoft.extensions.configuration/9.0.1", - "hashPath": "microsoft.extensions.configuration.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.Abstractions/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", - "path": "microsoft.extensions.configuration.abstractions/9.0.1", - "hashPath": "microsoft.extensions.configuration.abstractions.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.Binder/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-w7kAyu1Mm7eParRV6WvGNNwA8flPTub16fwH49h7b/yqJZFTgYxnOVCuiah3G2bgseJMEq4DLjjsyQRvsdzRgA==", - "path": "microsoft.extensions.configuration.binder/9.0.1", - "hashPath": "microsoft.extensions.configuration.binder.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.CommandLine/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-5WC1OsXfljC1KHEyL0yefpAyt1UZjrZ0/xyOqFowc5VntbE79JpCYOTSYFlxEuXm3Oq5xsgU2YXeZLTgAAX+DA==", - "path": "microsoft.extensions.configuration.commandline/9.0.1", - "hashPath": "microsoft.extensions.configuration.commandline.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.EnvironmentVariables/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-5HShUdF8KFAUSzoEu0DOFbX09FlcFtHxEalowyjM7Kji0EjdF0DLjHajb2IBvoqsExAYox+Z2GfbfGF7dH7lKQ==", - "path": "microsoft.extensions.configuration.environmentvariables/9.0.1", - "hashPath": "microsoft.extensions.configuration.environmentvariables.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.FileExtensions/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-QBOI8YVAyKqeshYOyxSe6co22oag431vxMu5xQe1EjXMkYE4xK4J71xLCW3/bWKmr9Aoy1VqGUARSLFnotk4Bg==", - "path": "microsoft.extensions.configuration.fileextensions/9.0.1", - "hashPath": "microsoft.extensions.configuration.fileextensions.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.Json/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-z+g+lgPET1JRDjsOkFe51rkkNcnJgvOK5UIpeTfF1iAi0GkBJz5/yUuTa8a9V8HUh4gj4xFT5WGoMoXoSDKfGg==", - "path": "microsoft.extensions.configuration.json/9.0.1", - "hashPath": "microsoft.extensions.configuration.json.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.UserSecrets/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-esGPOgLZ1tZddEomexhrU+LJ5YIsuJdkh0tU7r4WVpNZ15dLuMPqPW4Xe4txf3T2PDUX2ILe3nYQEDjZjfSEJg==", - "path": "microsoft.extensions.configuration.usersecrets/9.0.1", - "hashPath": "microsoft.extensions.configuration.usersecrets.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.DependencyInjection/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-qZI42ASAe3hr2zMSA6UjM92pO1LeDq5DcwkgSowXXPY8I56M76pEKrnmsKKbxagAf39AJxkH2DY4sb72ixyOrg==", - "path": "microsoft.extensions.dependencyinjection/9.0.1", - "hashPath": "microsoft.extensions.dependencyinjection.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.DependencyInjection.Abstractions/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-Tr74eP0oQ3AyC24ch17N8PuEkrPbD0JqIfENCYqmgKYNOmL8wQKzLJu3ObxTUDrjnn4rHoR1qKa37/eQyHmCDA==", - "path": "microsoft.extensions.dependencyinjection.abstractions/9.0.1", - "hashPath": "microsoft.extensions.dependencyinjection.abstractions.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Diagnostics/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-4ZmP6turxMFsNwK/MCko2fuIITaYYN/eXyyIRq1FjLDKnptdbn6xMb7u0zfSMzCGpzkx4RxH/g1jKN2IchG7uA==", - "path": "microsoft.extensions.diagnostics/9.0.1", - "hashPath": "microsoft.extensions.diagnostics.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Diagnostics.Abstractions/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-pfAPuVtHvG6dvZtAa0OQbXdDqq6epnr8z0/IIUjdmV0tMeI8Aj9KxDXvdDvqr+qNHTkmA7pZpChNxwNZt4GXVg==", - "path": "microsoft.extensions.diagnostics.abstractions/9.0.1", - "hashPath": "microsoft.extensions.diagnostics.abstractions.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.FileProviders.Abstractions/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-DguZYt1DWL05+8QKWL3b6bW7A2pC5kYFMY5iXM6W2M23jhvcNa8v6AU8PvVJBcysxHwr9/jax0agnwoBumsSwg==", - "path": "microsoft.extensions.fileproviders.abstractions/9.0.1", - "hashPath": "microsoft.extensions.fileproviders.abstractions.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.FileProviders.Physical/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-TKDMNRS66UTMEVT38/tU9hA63UTMvzI3DyNm5mx8+JCf3BaOtxgrvWLCI1y3J52PzT5yNl/T2KN5Z0KbApLZcg==", - "path": "microsoft.extensions.fileproviders.physical/9.0.1", - "hashPath": "microsoft.extensions.fileproviders.physical.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.FileSystemGlobbing/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-Mxcp9NXuQMvAnudRZcgIb5SqlWrlullQzntBLTwuv0MPIJ5LqiGwbRqiyxgdk+vtCoUkplb0oXy5kAw1t469Ug==", - "path": "microsoft.extensions.filesystemglobbing/9.0.1", - "hashPath": "microsoft.extensions.filesystemglobbing.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Hosting/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-3wZNcVvC8RW44HDqqmIq+BqF5pgmTQdbNdR9NyYw33JSMnJuclwoJ2PEkrJ/KvD1U/hmqHVL3l5If+Hn3D1fWA==", - "path": "microsoft.extensions.hosting/9.0.1", - "hashPath": "microsoft.extensions.hosting.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Hosting.Abstractions/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-CwSMhLNe8HLkfbFzdz0CHWJhtWH3TtfZSicLBd/itFD+NqQtfGHmvqXHQbaFFl3mQB5PBb2gxwzWQyW2pIj7PA==", - "path": "microsoft.extensions.hosting.abstractions/9.0.1", - "hashPath": "microsoft.extensions.hosting.abstractions.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Http/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-j1UmqmTRIc0OJhv8feVFmXhPS/Z+82o/JLF3WKlydC3esolPVVJPJ0oq/MSECXFZMBKVVpxUBJnR6dJH1hTWzQ==", - "path": "microsoft.extensions.http/9.0.1", - "hashPath": "microsoft.extensions.http.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Logging/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-E/k5r7S44DOW+08xQPnNbO8DKAQHhkspDboTThNJ6Z3/QBb4LC6gStNWzVmy3IvW7sUD+iJKf4fj0xEkqE7vnQ==", - "path": "microsoft.extensions.logging/9.0.1", - "hashPath": "microsoft.extensions.logging.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Logging.Abstractions/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-w2gUqXN/jNIuvqYwX3lbXagsizVNXYyt6LlF57+tMve4JYCEgCMMAjRce6uKcDASJgpMbErRT1PfHy2OhbkqEA==", - "path": "microsoft.extensions.logging.abstractions/9.0.1", - "hashPath": "microsoft.extensions.logging.abstractions.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Logging.Configuration/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-MeZePlyu3/74Wk4FHYSzXijADJUhWa7gxtaphLxhS8zEPWdJuBCrPo0sezdCSZaKCL+cZLSLobrb7xt2zHOxZQ==", - "path": "microsoft.extensions.logging.configuration/9.0.1", - "hashPath": "microsoft.extensions.logging.configuration.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Logging.Console/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-YUzguHYlWfp4upfYlpVe3dnY59P25wc+/YLJ9/NQcblT3EvAB1CObQulClll7NtnFbbx4Js0a0UfyS8SbRsWXQ==", - "path": "microsoft.extensions.logging.console/9.0.1", - "hashPath": "microsoft.extensions.logging.console.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Logging.Debug/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-pzdyibIV8k4sym0Sszcp2MJCuXrpOGs9qfOvY+hCRu8k4HbdVoeKOLnacxHK6vEPITX5o5FjjsZW2zScLXTjYA==", - "path": "microsoft.extensions.logging.debug/9.0.1", - "hashPath": "microsoft.extensions.logging.debug.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Logging.EventLog/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-+a4RlbwFWjsMujNNhf1Jy9Nm5CpMT+nxXxfgrkRSloPo0OAWhPSPsrFo6VWpvgIPPS41qmfAVWr3DqAmOoVZgQ==", - "path": "microsoft.extensions.logging.eventlog/9.0.1", - "hashPath": "microsoft.extensions.logging.eventlog.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Logging.EventSource/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-d47ZRZUOg1dGOX+yisWScQ7w4+92OlR9beS2UXaiadUCA3RFoZzobzVgrzBX7Oo/qefx9LxdRcaeFpWKb3BNBw==", - "path": "microsoft.extensions.logging.eventsource/9.0.1", - "hashPath": "microsoft.extensions.logging.eventsource.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Options/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-nggoNKnWcsBIAaOWHA+53XZWrslC7aGeok+aR+epDPRy7HI7GwMnGZE8yEsL2Onw7kMOHVHwKcsDls1INkNUJQ==", - "path": "microsoft.extensions.options/9.0.1", - "hashPath": "microsoft.extensions.options.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Options.ConfigurationExtensions/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-8RRKWtuU4fR+8MQLR/8CqZwZ9yc2xCpllw/WPRY7kskIqEq0hMcEI4AfUJO72yGiK2QJkrsDcUvgB5Yc+3+lyg==", - "path": "microsoft.extensions.options.configurationextensions/9.0.1", - "hashPath": "microsoft.extensions.options.configurationextensions.9.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Primitives/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==", - "path": "microsoft.extensions.primitives/9.0.1", - "hashPath": "microsoft.extensions.primitives.9.0.1.nupkg.sha512" - }, - "Microsoft.Net.Http.Headers/2.3.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-/M0wVg6tJUOHutWD3BMOUVZAioJVXe0tCpFiovzv0T9T12TBf4MnaHP0efO8TCr1a6O9RZgQeZ9Gdark8L9XdA==", - "path": "microsoft.net.http.headers/2.3.0", - "hashPath": "microsoft.net.http.headers.2.3.0.nupkg.sha512" - }, - "Microsoft.NETCore.Platforms/5.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==", - "path": "microsoft.netcore.platforms/5.0.0", - "hashPath": "microsoft.netcore.platforms.5.0.0.nupkg.sha512" - }, - "Microsoft.Toolkit.HighPerformance/7.1.2": { - "type": "package", - "serviceable": true, - "sha512": "sha512-cezzRky0BUJyYmSrcQUcX8qAv90JfUwCqWEbqfWZLHyeANo9/LWgW6y50pqbyc8r8SPXVsu2GNH98fB3VxrnvA==", - "path": "microsoft.toolkit.highperformance/7.1.2", - "hashPath": "microsoft.toolkit.highperformance.7.1.2.nupkg.sha512" - }, - "Microsoft.Win32.Registry/5.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", - "path": "microsoft.win32.registry/5.0.0", - "hashPath": "microsoft.win32.registry.5.0.0.nupkg.sha512" - }, - "Microsoft.Win32.SystemEvents/5.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-Bh6blKG8VAKvXiLe2L+sEsn62nc1Ij34MrNxepD2OCrS5cpCwQa9MeLyhVQPQ/R4Wlzwuy6wMK8hLb11QPDRsQ==", - "path": "microsoft.win32.systemevents/5.0.0", - "hashPath": "microsoft.win32.systemevents.5.0.0.nupkg.sha512" - }, - "NETStandard.Library/2.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-7jnbRU+L08FXKMxqUflxEXtVymWvNOrS8yHgu9s6EM8Anr6T/wIX4nZ08j/u3Asz+tCufp3YVwFSEvFTPYmBPA==", - "path": "netstandard.library/2.0.0", - "hashPath": "netstandard.library.2.0.0.nupkg.sha512" - }, - "Nettle/3.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-4L4Ci6STNLPufw58JrA0s2YlGj+MWwo5UVEpYsUiG5d7ctCYe1xbWcB7gnAfUijHEB1MVAbcgGbUsfE+MMfCgA==", - "path": "nettle/3.0.0", - "hashPath": "nettle.3.0.0.nupkg.sha512" - }, - "Newtonsoft.Json/13.0.3": { - "type": "package", - "serviceable": true, - "sha512": "sha512-HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==", - "path": "newtonsoft.json/13.0.3", - "hashPath": "newtonsoft.json.13.0.3.nupkg.sha512" - }, - "NexusMods.Paths/0.10.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-5pv6XROhpTAMcSNdJncAZmY9q7xuevKLPO1wMOw0vWQBEB2Po9vcwAnxUkNzv4LFzprlXAHa+hKVa+rXoq5P7g==", - "path": "nexusmods.paths/0.10.0", - "hashPath": "nexusmods.paths.0.10.0.nupkg.sha512" - }, - "NLog/5.3.4": { - "type": "package", - "serviceable": true, - "sha512": "sha512-gLy7+O1hEYJXIlcTr1/VWjGXrZTQFZzYNO18IWasD64pNwz0BreV+nHLxWKXWZzERRzoKnsk2XYtwLkTVk7J1A==", - "path": "nlog/5.3.4", - "hashPath": "nlog.5.3.4.nupkg.sha512" - }, - "NLog.Extensions.Logging/5.3.15": { - "type": "package", - "serviceable": true, - "sha512": "sha512-BSCkfWjaTesw4/k8Ujj3YdqtDzd2iCRYIOwoHume7aaJFz4/gLbemA5MLgkAwtCxcd8nGBGfaEkeIzQPGxArLg==", - "path": "nlog.extensions.logging/5.3.15", - "hashPath": "nlog.extensions.logging.5.3.15.nupkg.sha512" - }, - "Octokit/14.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw==", - "path": "octokit/14.0.0", - "hashPath": "octokit.14.0.0.nupkg.sha512" - }, - "Octopus.Octodiff/2.0.548": { - "type": "package", - "serviceable": true, - "sha512": "sha512-kbT0ympzmcpwf2/OxZKzL3My+j4HdZpc0cDWX60t1iaNtJXzZl2S/shEmDZxBlsfQieb8esH2GOAQuAZ6knD3w==", - "path": "octopus.octodiff/2.0.548", - "hashPath": "octopus.octodiff.2.0.548.nupkg.sha512" - }, - "OMODFramework/3.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-jpo14v6B0Mq35cunVbQM7PXaZvtI3Q0IE7u1WFylcW5U4B7s8K4OAnciZQRz14MsRMmw4qpyyQ0qWvZxKtNXMg==", - "path": "omodframework/3.0.1", - "hashPath": "omodframework.3.0.1.nupkg.sha512" - }, - "OneOf/3.0.271": { - "type": "package", - "serviceable": true, - "sha512": "sha512-pqpqeK8xQGggExhr4tesVgJkjdn+9HQAO0QgrYV2hFjE3y90okzk1kQMntMiUOGfV7FrCUfKPaVvPBD4IANqKg==", - "path": "oneof/3.0.271", - "hashPath": "oneof.3.0.271.nupkg.sha512" - }, - "Reloaded.Memory/9.4.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-wld6juhxdyvC5e5Bls39M6kb/cFYgNy3zD88f+PEedp6Tx1ASc4eZxff45fyp5H91zqDDnVNerC/FFjUK74inQ==", - "path": "reloaded.memory/9.4.1", - "hashPath": "reloaded.memory.9.4.1.nupkg.sha512" - }, - "SHA3.Net/2.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-ryLRgwVNnWLNW8fkl1qfr13eBkNhiRl2dPIksfEgcQghxrOt21PAEBxg/ofwW2l0FKanYpWT9WzD27O3PGHqpw==", - "path": "sha3.net/2.0.0", - "hashPath": "sha3.net.2.0.0.nupkg.sha512" - }, - "SharpCompress/0.28.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-EM0NUv9givYJXAVCLrC5DXWT+EA+69N3DppwO1OTROU5ZIIOLlnHu6JfGt81mgi5Q8xQFQJdHmEDbisiLgpfzg==", - "path": "sharpcompress/0.28.1", - "hashPath": "sharpcompress.0.28.1.nupkg.sha512" - }, - "SharpZipLib/1.4.2": { - "type": "package", - "serviceable": true, - "sha512": "sha512-yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==", - "path": "sharpziplib/1.4.2", - "hashPath": "sharpziplib.1.4.2.nupkg.sha512" - }, - "Shipwreck.Phash/0.5.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-FMeI3ZkPAjr/KlIIyagMoFCdUVADj654njRVcUC172sBGUAqdc6LRBD/3Dj9SZxbeSL1iJU4iHCTakVgva9ukQ==", - "path": "shipwreck.phash/0.5.0", - "hashPath": "shipwreck.phash.0.5.0.nupkg.sha512" - }, - "shortid/4.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-iiOsFHiZLfmUxViNKWAmLYi6xCJanKBYlxe163pkK4C+k9vnkWdCNCPouNWukn/F147k3bLq0tEFEQhlGMLqjA==", - "path": "shortid/4.0.0", - "hashPath": "shortid.4.0.0.nupkg.sha512" - }, - "SixLabors.ImageSharp/3.1.6": { - "type": "package", - "serviceable": true, - "sha512": "sha512-dHQ5jugF9v+5/LCVHCWVzaaIL6WOehqJy6eju/0VFYFPEj2WtqkGPoEV9EVQP83dHsdoqYaTuWpZdwAd37UwfA==", - "path": "sixlabors.imagesharp/3.1.6", - "hashPath": "sixlabors.imagesharp.3.1.6.nupkg.sha512" - }, - "Stub.System.Data.SQLite.Core.NetStandard/1.0.119": { - "type": "package", - "serviceable": true, - "sha512": "sha512-dI7ngiCNgdm+n00nQvFTa+LbHvE9MIQXwMSLRzJI/KAJ7G1WmCachsvfE1CD6xvb3OXJvYYEfv3+S/LHyhN0Rg==", - "path": "stub.system.data.sqlite.core.netstandard/1.0.119", - "hashPath": "stub.system.data.sqlite.core.netstandard.1.0.119.nupkg.sha512" - }, - "System.Buffers/4.6.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-lN6tZi7Q46zFzAbRYXTIvfXcyvQQgxnY7Xm6C6xQ9784dEL1amjM6S6Iw4ZpsvesAKnRVsM4scrDQaDqSClkjA==", - "path": "system.buffers/4.6.0", - "hashPath": "system.buffers.4.6.0.nupkg.sha512" - }, - "System.CodeDom/8.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-WTlRjL6KWIMr/pAaq3rYqh0TJlzpouaQ/W1eelssHgtlwHAH25jXTkUphTYx9HaIIf7XA6qs/0+YhtLEQRkJ+Q==", - "path": "system.codedom/8.0.0", - "hashPath": "system.codedom.8.0.0.nupkg.sha512" - }, - "System.CommandLine/2.0.0-beta4.22272.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-1uqED/q2H0kKoLJ4+hI2iPSBSEdTuhfCYADeJrAqERmiGQ2NNacYKRNEQ+gFbU4glgVyK8rxI+ZOe1onEtr/Pg==", - "path": "system.commandline/2.0.0-beta4.22272.1", - "hashPath": "system.commandline.2.0.0-beta4.22272.1.nupkg.sha512" - }, - "System.CommandLine.NamingConventionBinder/2.0.0-beta4.22272.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-ux2eUA/syF+JtlpMDc/Lsd6PBIBuwjH3AvHnestoh5uD0WKT5b+wkQxDWVCqp9qgVjMBTLNhX19ZYFtenunt9A==", - "path": "system.commandline.namingconventionbinder/2.0.0-beta4.22272.1", - "hashPath": "system.commandline.namingconventionbinder.2.0.0-beta4.22272.1.nupkg.sha512" - }, - "System.Data.SQLite.Core/1.0.119": { - "type": "package", - "serviceable": true, - "sha512": "sha512-bhQB8HVtRA+OOYw8UTD1F1kU+nGJ0/OZvH1JmlVUI4bGvgVEWeX1NcHjA765NvUoRVuCPlt8PrEpZ1thSsk1jg==", - "path": "system.data.sqlite.core/1.0.119", - "hashPath": "system.data.sqlite.core.1.0.119.nupkg.sha512" - }, - "System.Diagnostics.DiagnosticSource/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-yOcDWx4P/s1I83+7gQlgQLmhny2eNcU0cfo1NBWi+en4EAI38Jau+/neT85gUW6w1s7+FUJc2qNOmmwGLIREqA==", - "path": "system.diagnostics.diagnosticsource/9.0.1", - "hashPath": "system.diagnostics.diagnosticsource.9.0.1.nupkg.sha512" - }, - "System.Diagnostics.EventLog/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==", - "path": "system.diagnostics.eventlog/9.0.1", - "hashPath": "system.diagnostics.eventlog.9.0.1.nupkg.sha512" - }, - "System.Drawing.Common/5.0.2": { - "type": "package", - "serviceable": true, - "sha512": "sha512-rvr/M1WPf24ljpvvrVd74+NdjRUJu1bBkspkZcnzSZnmAUQWSvanlQ0k/hVHk+cHufZbZfu7vOh/vYc0q5Uu/A==", - "path": "system.drawing.common/5.0.2", - "hashPath": "system.drawing.common.5.0.2.nupkg.sha512" - }, - "System.IO.Pipelines/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg==", - "path": "system.io.pipelines/9.0.1", - "hashPath": "system.io.pipelines.9.0.1.nupkg.sha512" - }, - "System.Management/8.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-jrK22i5LRzxZCfGb+tGmke2VH7oE0DvcDlJ1HAKYU8cPmD8XnpUT0bYn2Gy98GEhGjtfbR/sxKTVb+dE770pfA==", - "path": "system.management/8.0.0", - "hashPath": "system.management.8.0.0.nupkg.sha512" - }, - "System.Memory/4.5.4": { - "type": "package", - "serviceable": true, - "sha512": "sha512-1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==", - "path": "system.memory/4.5.4", - "hashPath": "system.memory.4.5.4.nupkg.sha512" - }, - "System.Reactive/6.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-rHaWtKDwCi9qJ3ObKo8LHPMuuwv33YbmQi7TcUK1C264V3MFnOr5Im7QgCTdLniztP3GJyeiSg5x8NqYJFqRmg==", - "path": "system.reactive/6.0.1", - "hashPath": "system.reactive.6.0.1.nupkg.sha512" - }, - "System.Security.AccessControl/5.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", - "path": "system.security.accesscontrol/5.0.0", - "hashPath": "system.security.accesscontrol.5.0.0.nupkg.sha512" - }, - "System.Security.Principal.Windows/5.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==", - "path": "system.security.principal.windows/5.0.0", - "hashPath": "system.security.principal.windows.5.0.0.nupkg.sha512" - }, - "System.Text.Encoding.CodePages/5.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-NyscU59xX6Uo91qvhOs2Ccho3AR2TnZPomo1Z0K6YpyztBPM/A5VbkzOO19sy3A3i1TtEnTxA7bCe3Us+r5MWg==", - "path": "system.text.encoding.codepages/5.0.0", - "hashPath": "system.text.encoding.codepages.5.0.0.nupkg.sha512" - }, - "System.Text.Encodings.Web/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==", - "path": "system.text.encodings.web/9.0.1", - "hashPath": "system.text.encodings.web.9.0.1.nupkg.sha512" - }, - "System.Text.Json/9.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-eqWHDZqYPv1PvuvoIIx5pF74plL3iEOZOl/0kQP+Y0TEbtgNnM2W6k8h8EPYs+LTJZsXuWa92n5W5sHTWvE3VA==", - "path": "system.text.json/9.0.1", - "hashPath": "system.text.json.9.0.1.nupkg.sha512" - }, - "System.Threading.Tasks.Extensions/4.5.4": { - "type": "package", - "serviceable": true, - "sha512": "sha512-zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", - "path": "system.threading.tasks.extensions/4.5.4", - "hashPath": "system.threading.tasks.extensions.4.5.4.nupkg.sha512" - }, - "ValveKeyValue/0.10.0.360": { - "type": "package", - "serviceable": true, - "sha512": "sha512-APiKxT2Qyg/Kpgylpd/Oylta9xL076qj5cWkN+bpDPrpVhsM6FKQG5nELBao2eGXeqIeGI8LUQQ9Q4Sppix6Cw==", - "path": "valvekeyvalue/0.10.0.360", - "hashPath": "valvekeyvalue.0.10.0.360.nupkg.sha512" - }, - "YamlDotNet/16.3.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA==", - "path": "yamldotnet/16.3.0", - "hashPath": "yamldotnet.16.3.0.nupkg.sha512" - }, - "Wabbajack.CLI.Builder/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Common/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Compiler/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Compression.BSA/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Compression.Zip/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Configuration/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.Bethesda/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.Dispatcher/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.GameFile/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.GoogleDrive/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.Http/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.Interfaces/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.Manual/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.MediaFire/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.Mega/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.ModDB/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.Nexus/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.VerificationCache/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Downloaders.WabbajackCDN/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.DTOs/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.FileExtractor/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Hashing.PHash/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Hashing.xxHash64/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Installer/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.IO.Async/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Networking.BethesdaNet/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Networking.Discord/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Networking.GitHub/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Networking.Http/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Networking.Http.Interfaces/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Networking.NexusApi/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Networking.WabbajackClientApi/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Paths/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Paths.IO/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.RateLimiter/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Server.Lib/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.Services.OSIntegrated/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.VFS/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "Wabbajack.VFS.Interfaces/0.5.4": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "MegaApiClient/1.0.0.0": { - "type": "reference", - "serviceable": false, - "sha512": "" - } - }, - "runtimes": { - "android-x64": [ - "android", - "linux-bionic-x64", - "linux-bionic", - "linux-x64", - "linux", - "unix-x64", - "unix", - "any", - "base" - ], - "linux-bionic-x64": [ - "linux-bionic", - "linux-x64", - "linux", - "unix-x64", - "unix", - "any", - "base" - ], - "linux-musl-x64": [ - "linux-musl", - "linux-x64", - "linux", - "unix-x64", - "unix", - "any", - "base" - ], - "linux-x64": [ - "linux", - "unix-x64", - "unix", - "any", - "base" - ], - "fedora.43-x64": [ - "linux-x64", - "linux", - "unix-x64", - "unix", - "any", - "base" - ] - } -} \ No newline at end of file diff --git a/jackify/engine/jackify-engine.dll b/jackify/engine/jackify-engine.dll deleted file mode 100644 index 8444609..0000000 Binary files a/jackify/engine/jackify-engine.dll and /dev/null differ diff --git a/jackify/engine/jackify-engine.runtimeconfig.json b/jackify/engine/jackify-engine.runtimeconfig.json deleted file mode 100644 index 1c75f9a..0000000 --- a/jackify/engine/jackify-engine.runtimeconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "runtimeOptions": { - "tfm": "net8.0", - "includedFrameworks": [ - { - "name": "Microsoft.NETCore.App", - "version": "8.0.25" - } - ], - "configProperties": { - "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, - "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false, - "System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": true - } - } -} \ No newline at end of file diff --git a/jackify/engine/ko/System.CommandLine.resources.dll b/jackify/engine/ko/System.CommandLine.resources.dll deleted file mode 100755 index 910483d..0000000 Binary files a/jackify/engine/ko/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/engine/libSystem.Globalization.Native.so b/jackify/engine/libSystem.Globalization.Native.so deleted file mode 100755 index 7cffdff..0000000 Binary files a/jackify/engine/libSystem.Globalization.Native.so and /dev/null differ diff --git a/jackify/engine/libSystem.IO.Compression.Native.so b/jackify/engine/libSystem.IO.Compression.Native.so deleted file mode 100755 index f40a37e..0000000 Binary files a/jackify/engine/libSystem.IO.Compression.Native.so and /dev/null differ diff --git a/jackify/engine/libSystem.Native.so b/jackify/engine/libSystem.Native.so deleted file mode 100755 index 06d53e9..0000000 Binary files a/jackify/engine/libSystem.Native.so and /dev/null differ diff --git a/jackify/engine/libSystem.Net.Security.Native.so b/jackify/engine/libSystem.Net.Security.Native.so deleted file mode 100755 index 178131b..0000000 Binary files a/jackify/engine/libSystem.Net.Security.Native.so and /dev/null differ diff --git a/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so b/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so deleted file mode 100755 index 327f8d2..0000000 Binary files a/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so and /dev/null differ diff --git a/jackify/engine/libclrgc.so b/jackify/engine/libclrgc.so deleted file mode 100755 index 5dd7970..0000000 Binary files a/jackify/engine/libclrgc.so and /dev/null differ diff --git a/jackify/engine/libclrjit.so b/jackify/engine/libclrjit.so deleted file mode 100755 index fa10bca..0000000 Binary files a/jackify/engine/libclrjit.so and /dev/null differ diff --git a/jackify/engine/libcoreclr.so b/jackify/engine/libcoreclr.so deleted file mode 100755 index 412b8a5..0000000 Binary files a/jackify/engine/libcoreclr.so and /dev/null differ diff --git a/jackify/engine/libcoreclrtraceptprovider.so b/jackify/engine/libcoreclrtraceptprovider.so deleted file mode 100755 index cfb2775..0000000 Binary files a/jackify/engine/libcoreclrtraceptprovider.so and /dev/null differ diff --git a/jackify/engine/libhostfxr.so b/jackify/engine/libhostfxr.so deleted file mode 100755 index bdf7761..0000000 Binary files a/jackify/engine/libhostfxr.so and /dev/null differ diff --git a/jackify/engine/libhostpolicy.so b/jackify/engine/libhostpolicy.so deleted file mode 100755 index 85db7ab..0000000 Binary files a/jackify/engine/libhostpolicy.so and /dev/null differ diff --git a/jackify/engine/libmscordaccore.so b/jackify/engine/libmscordaccore.so deleted file mode 100755 index 076428e..0000000 Binary files a/jackify/engine/libmscordaccore.so and /dev/null differ diff --git a/jackify/engine/libmscordbi.so b/jackify/engine/libmscordbi.so deleted file mode 100755 index 16522b6..0000000 Binary files a/jackify/engine/libmscordbi.so and /dev/null differ diff --git a/jackify/engine/mscorlib.dll b/jackify/engine/mscorlib.dll deleted file mode 100755 index e4b35e5..0000000 Binary files a/jackify/engine/mscorlib.dll and /dev/null differ diff --git a/jackify/engine/netstandard.dll b/jackify/engine/netstandard.dll deleted file mode 100755 index a4bbec9..0000000 Binary files a/jackify/engine/netstandard.dll and /dev/null differ diff --git a/jackify/engine/pl/System.CommandLine.resources.dll b/jackify/engine/pl/System.CommandLine.resources.dll deleted file mode 100755 index bfee4aa..0000000 Binary files a/jackify/engine/pl/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/engine/pt-BR/System.CommandLine.resources.dll b/jackify/engine/pt-BR/System.CommandLine.resources.dll deleted file mode 100755 index 8fc42f2..0000000 Binary files a/jackify/engine/pt-BR/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/engine/ru/System.CommandLine.resources.dll b/jackify/engine/ru/System.CommandLine.resources.dll deleted file mode 100755 index e98f737..0000000 Binary files a/jackify/engine/ru/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/engine/runtimes/browser/lib/net8.0/System.Text.Encodings.Web.dll b/jackify/engine/runtimes/browser/lib/net8.0/System.Text.Encodings.Web.dll deleted file mode 100755 index faef332..0000000 Binary files a/jackify/engine/runtimes/browser/lib/net8.0/System.Text.Encodings.Web.dll and /dev/null differ diff --git a/jackify/engine/runtimes/linux-x64/native/SQLite.Interop.dll b/jackify/engine/runtimes/linux-x64/native/SQLite.Interop.dll deleted file mode 100755 index 6cc6b46..0000000 Binary files a/jackify/engine/runtimes/linux-x64/native/SQLite.Interop.dll and /dev/null differ diff --git a/jackify/engine/runtimes/osx-x64/native/SQLite.Interop.dll b/jackify/engine/runtimes/osx-x64/native/SQLite.Interop.dll deleted file mode 100755 index e320817..0000000 Binary files a/jackify/engine/runtimes/osx-x64/native/SQLite.Interop.dll and /dev/null differ diff --git a/jackify/engine/runtimes/unix/lib/netcoreapp3.0/System.Drawing.Common.dll b/jackify/engine/runtimes/unix/lib/netcoreapp3.0/System.Drawing.Common.dll deleted file mode 100755 index 8b95164..0000000 Binary files a/jackify/engine/runtimes/unix/lib/netcoreapp3.0/System.Drawing.Common.dll and /dev/null differ diff --git a/jackify/engine/runtimes/win-x64/native/SQLite.Interop.dll b/jackify/engine/runtimes/win-x64/native/SQLite.Interop.dll deleted file mode 100755 index 884b2d4..0000000 Binary files a/jackify/engine/runtimes/win-x64/native/SQLite.Interop.dll and /dev/null differ diff --git a/jackify/engine/runtimes/win-x86/native/SQLite.Interop.dll b/jackify/engine/runtimes/win-x86/native/SQLite.Interop.dll deleted file mode 100755 index 98e74c4..0000000 Binary files a/jackify/engine/runtimes/win-x86/native/SQLite.Interop.dll and /dev/null differ diff --git a/jackify/engine/runtimes/win/lib/net8.0/System.Diagnostics.EventLog.Messages.dll b/jackify/engine/runtimes/win/lib/net8.0/System.Diagnostics.EventLog.Messages.dll deleted file mode 100755 index 457aaad..0000000 Binary files a/jackify/engine/runtimes/win/lib/net8.0/System.Diagnostics.EventLog.Messages.dll and /dev/null differ diff --git a/jackify/engine/runtimes/win/lib/net8.0/System.Diagnostics.EventLog.dll b/jackify/engine/runtimes/win/lib/net8.0/System.Diagnostics.EventLog.dll deleted file mode 100755 index 4c50999..0000000 Binary files a/jackify/engine/runtimes/win/lib/net8.0/System.Diagnostics.EventLog.dll and /dev/null differ diff --git a/jackify/engine/runtimes/win/lib/net8.0/System.Management.dll b/jackify/engine/runtimes/win/lib/net8.0/System.Management.dll deleted file mode 100755 index c7505b3..0000000 Binary files a/jackify/engine/runtimes/win/lib/net8.0/System.Management.dll and /dev/null differ diff --git a/jackify/engine/runtimes/win/lib/netcoreapp3.0/Microsoft.Win32.SystemEvents.dll b/jackify/engine/runtimes/win/lib/netcoreapp3.0/Microsoft.Win32.SystemEvents.dll deleted file mode 100755 index b5aa0c4..0000000 Binary files a/jackify/engine/runtimes/win/lib/netcoreapp3.0/Microsoft.Win32.SystemEvents.dll and /dev/null differ diff --git a/jackify/engine/runtimes/win/lib/netcoreapp3.0/System.Drawing.Common.dll b/jackify/engine/runtimes/win/lib/netcoreapp3.0/System.Drawing.Common.dll deleted file mode 100755 index b80b430..0000000 Binary files a/jackify/engine/runtimes/win/lib/netcoreapp3.0/System.Drawing.Common.dll and /dev/null differ diff --git a/jackify/engine/shortid.dll b/jackify/engine/shortid.dll deleted file mode 100644 index 31fe7d7..0000000 Binary files a/jackify/engine/shortid.dll and /dev/null differ diff --git a/jackify/engine/test.png b/jackify/engine/test.png deleted file mode 100644 index 661c481..0000000 Binary files a/jackify/engine/test.png and /dev/null differ diff --git a/jackify/engine/tr/System.CommandLine.resources.dll b/jackify/engine/tr/System.CommandLine.resources.dll deleted file mode 100755 index 6e29e3e..0000000 Binary files a/jackify/engine/tr/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/engine/zh-Hans/System.CommandLine.resources.dll b/jackify/engine/zh-Hans/System.CommandLine.resources.dll deleted file mode 100755 index 06e46a1..0000000 Binary files a/jackify/engine/zh-Hans/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/engine/zh-Hant/System.CommandLine.resources.dll b/jackify/engine/zh-Hant/System.CommandLine.resources.dll deleted file mode 100755 index afb03e9..0000000 Binary files a/jackify/engine/zh-Hant/System.CommandLine.resources.dll and /dev/null differ diff --git a/jackify/frontends/cli/commands/configure_modlist.py b/jackify/frontends/cli/commands/configure_modlist.py index 7b0eddc..d4d109c 100644 --- a/jackify/frontends/cli/commands/configure_modlist.py +++ b/jackify/frontends/cli/commands/configure_modlist.py @@ -1,164 +1,269 @@ -""" -Configure Modlist Command +"""Configure Modlist Command: CLI command for configuring a modlist post-install.""" -CLI command for configuring a modlist post-install. -Extracted from the original jackify-cli.py. -""" - -import os import logging +import os +import threading +from pathlib import Path from typing import Optional -# Import the backend services we'll need -from jackify.backend.models.configuration import ConfigurationContext -from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET +from jackify.shared.colors import ( + COLOR_INFO, + COLOR_ERROR, + COLOR_WARNING, + COLOR_PROMPT, + COLOR_RESET, + COLOR_SUCCESS, +) logger = logging.getLogger(__name__) class ConfigureModlistCommand: """Handler for the configure-modlist CLI command.""" - - def __init__(self, backend_services): - """Initialize with backend services. - - Args: - backend_services: Dictionary of backend service instances - """ - self.backend_services = backend_services - self.test_mode = False # TODO: Get from global config - - def add_parser(self, subparsers): - """Add the configure-modlist subcommand parser. - - Args: - subparsers: The ArgumentParser subparsers object - """ - configure_modlist_parser = subparsers.add_parser( - "configure-modlist", - help="Configure a modlist post-install (for GUI integration)" - ) - configure_modlist_parser.add_argument( - "--modlist-name", - type=str, - required=True, - help="Name of the modlist to configure (Steam shortcut name)" - ) - configure_modlist_parser.add_argument( - "--install-dir", - type=str, - required=True, - help="Install directory of the modlist" - ) - configure_modlist_parser.add_argument( - "--download-dir", - type=str, - help="Downloads directory (optional)" - ) - configure_modlist_parser.add_argument( - "--nexus-api-key", - type=str, - help="Nexus API key (optional)" - ) - configure_modlist_parser.add_argument( - "--mo2-exe-path", - type=str, - help="Path to ModOrganizer.exe (for AppID lookup)" - ) - configure_modlist_parser.add_argument( - "--resolution", - type=str, - help="Resolution to set (optional)" - ) - configure_modlist_parser.add_argument( - "--skip-confirmation", - action='store_true', - help="Skip confirmation prompts" - ) - return configure_modlist_parser - - def execute(self, args) -> int: - """Execute the configure-modlist command. - - Args: - args: Parsed command-line arguments - - Returns: - Exit code (0 for success, 1 for failure) - """ - logger.info("Starting non-interactive modlist configuration (CLI mode)") - - try: - # Build configuration context from args - context = self._build_context_from_args(args) - # Use legacy implementation for now - will migrate to backend services later + def __init__(self, backend_services): + self.backend_services = backend_services + self.test_mode = False + + def add_parser(self, subparsers): + p = subparsers.add_parser( + "configure-modlist", + help="Configure a modlist post-install (for GUI integration)", + ) + p.add_argument("--modlist-name", type=str, required=True, + help="Name of the modlist to configure (Steam shortcut name)") + p.add_argument("--install-dir", type=str, required=True, + help="Install directory of the modlist") + p.add_argument("--download-dir", type=str, help="Downloads directory (optional)") + p.add_argument("--nexus-api-key", type=str, help="Nexus API key (optional)") + p.add_argument("--mo2-exe-path", type=str, + help="Path to ModOrganizer.exe (for AppID lookup)") + p.add_argument("--resolution", type=str, help="Resolution to set (optional)") + p.add_argument("--skip-confirmation", action="store_true", + help="Skip confirmation prompts") + return p + + def execute(self, args) -> int: + logger.info("Starting non-interactive modlist configuration (CLI mode)") + + try: + context = self._build_context_from_args(args) result = self._execute_legacy_configuration(context) + if result is not True: + logger.info("Finished non-interactive modlist configuration") + return 1 + logger.info("Finished non-interactive modlist configuration") - if not getattr(args, 'skip_confirmation', False) and context.get('install_dir'): - from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible - prompt_ttw_if_eligible(context['install_dir'], context.get('modlist_name') or '') + install_dir = context.get("install_dir", "") or "" + modlist_name = context.get("modlist_name", "") or "" + skip_confirm = bool(context.get("skip_confirmation", False)) + + if not install_dir: + return 0 + + game_type = self._detect_game_type(install_dir) + app_id = self._lookup_app_id(install_dir) + + self._run_post_configure_hooks( + install_dir, modlist_name, game_type, app_id, skip_confirm + ) + + return 0 - return 0 if result is True else 1 - except Exception as e: - logger.error(f"Failed to configure modlist: {e}") + logger.error("Failed to configure modlist: %s", e) print(f"{COLOR_ERROR}Configuration failed: {e}{COLOR_RESET}") return 1 - + + # ------------------------------------------------------------------ + # Post-configure hooks (mirrors configuration_phase in install path) + # ------------------------------------------------------------------ + + def _run_post_configure_hooks( + self, + install_dir: str, + modlist_name: str, + game_type: str, + app_id: Optional[str], + skip_confirm: bool, + ) -> None: + # TTW + try: + from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible + prompt_ttw_if_eligible(install_dir, modlist_name) + except Exception as e: + logger.error("TTW post-install prompt failed: %s", e, exc_info=True) + print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}") + + # VNV + try: + from jackify.backend.services.vnv_integration_helper import ( + run_vnv_automation_if_applicable, + should_offer_vnv_automation, + ) + if should_offer_vnv_automation(modlist_name, Path(install_dir)): + def _confirm_vnv(description: str) -> bool: + print(f"\n{description}\n") + try: + ans = input(f"{COLOR_PROMPT}Run VNV post-install automation now? (Y/n): {COLOR_RESET}").strip().lower() + except (EOFError, KeyboardInterrupt): + return False + return ans in ("", "y", "yes") + + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + automation_ran, vnv_error = run_vnv_automation_if_applicable( + modlist_name=modlist_name, + modlist_install_location=Path(install_dir), + game_root=None, + ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), + progress_callback=print, + manual_file_callback=None, + confirmation_callback=_confirm_vnv, + ) + if automation_ran and not vnv_error: + print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}") + if vnv_error: + print(f"{COLOR_WARNING}VNV automation encountered an error: {vnv_error}{COLOR_RESET}") + except Exception as e: + logger.error("VNV post-install automation failed: %s", e, exc_info=True) + print(f"{COLOR_WARNING}VNV automation could not be completed. Check logs for details.{COLOR_RESET}") + + # JContainers + try: + from jackify.backend.handlers.modlist_fixup_handler import ( + check_jcontainers_needs_fix, + apply_jcontainers_fix, + ) + if check_jcontainers_needs_fix(Path(install_dir), game_type): + print(f"\n{COLOR_WARNING}JContainers Compatibility Fix{COLOR_RESET}") + print(f"{COLOR_INFO}The mod JContainers has been detected. The Nexusmods version is known to cause crashes on Linux/Proton.{COLOR_RESET}") + print(f"{COLOR_INFO}A fixed version is available from the mod's GitHub page. The original DLL will be backed up first.{COLOR_RESET}") + try: + user_input = input(f"{COLOR_PROMPT}Apply JContainers fix now? (Y/n): {COLOR_RESET}").strip().lower() + except (EOFError, KeyboardInterrupt): + user_input = "n" + if user_input in ("", "y", "yes"): + apply_jcontainers_fix(Path(install_dir), game_type) + print(f"{COLOR_INFO}JContainers fix applied.{COLOR_RESET}") + else: + print(f"{COLOR_INFO}JContainers fix skipped.{COLOR_RESET}") + except Exception as e: + logger.warning("JContainers fix check failed (non-fatal): %s", e) + + # Steam artwork + if app_id: + try: + from jackify.backend.handlers.modlist_handler import ModlistHandler + ModlistHandler().set_steam_grid_images(str(app_id), install_dir, game_type=game_type) + logger.info("Steam artwork applied for app_id %s", app_id) + except Exception as e: + logger.warning("Steam artwork failed: %s", e) + + # Install verification + try: + from jackify.backend.services.install_verifier_service import ( + run_install_verification, + resolve_pfx_for_appid, + ) + from jackify.frontends.cli.ui.indeterminate_status import CliIndeterminateStatus + _pfx = resolve_pfx_for_appid(str(app_id)) if app_id else None + if _pfx and _pfx.is_dir(): + _verif_result: list = [None] + _spinner = CliIndeterminateStatus() + _spinner.set("Running install verification...") + + def _verif_worker() -> None: + _verif_result[0] = run_install_verification( + _pfx, + Path(install_dir), + game_type or "", + str(app_id) if app_id else "", + modlist_name, + ) + + t = threading.Thread(target=_verif_worker, daemon=True) + t.start() + t.join() + _spinner.stop() + r = _verif_result[0] + if r is not None: + n_pass = len(r.passes) if hasattr(r, "passes") else 0 + n_warn = len(r.warnings) if hasattr(r, "warnings") else 0 + n_fail = len(r.failures) if hasattr(r, "failures") else 0 + print(f"{COLOR_INFO}Install verification: {n_pass} passed, {n_warn} warnings, {n_fail} failed{COLOR_RESET}") + if hasattr(r, "failures"): + for msg in r.failures: + print(f" {COLOR_WARNING}[FAIL] {msg}{COLOR_RESET}") + if hasattr(r, "warnings"): + for msg in r.warnings: + print(f" {COLOR_INFO}[WARN] {msg}{COLOR_RESET}") + except Exception as e: + logger.warning("Install verification failed: %s", e) + + print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}") + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _detect_game_type(self, install_dir: str) -> str: + try: + ini = os.path.join(install_dir, "ModOrganizer.ini") + if os.path.isfile(ini): + from jackify.backend.handlers.modlist_handler import ModlistHandler + handler = ModlistHandler({}) + handler.modlist_ini = ini + handler.modlist_dir = install_dir + if handler._detect_game_variables(): + return handler.game_var_full or "" + except Exception as e: + logger.debug("Game type detection failed: %s", e) + return "" + + def _lookup_app_id(self, install_dir: str) -> Optional[str]: + try: + from jackify.backend.handlers.shortcut_handler import ShortcutHandler + from jackify.backend.services.platform_detection_service import PlatformDetectionService + platform_service = PlatformDetectionService.get_instance() + sh = ShortcutHandler(steamdeck=platform_service.is_steamdeck, verbose=False) + for sc in sh.find_shortcuts_by_exe("ModOrganizer.exe"): + if os.path.realpath(sc.get("StartDir", "")) == os.path.realpath(install_dir): + raw = sc.get("appid") + if raw is not None: + return str(int(raw) & 0xFFFFFFFF) + except Exception as e: + logger.debug("AppID lookup failed: %s", e) + return None + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + def _build_context_from_args(self, args) -> dict: - """Build context dictionary from command arguments. - - Args: - args: Parsed command-line arguments - - Returns: - Context dictionary - """ return { - 'modlist_name': getattr(args, 'modlist_name', None), - 'install_dir': getattr(args, 'install_dir', None), - 'download_dir': getattr(args, 'download_dir', None), - 'nexus_api_key': getattr(args, 'nexus_api_key', os.environ.get('NEXUS_API_KEY')), - 'mo2_exe_path': getattr(args, 'mo2_exe_path', None), - 'resolution': getattr(args, 'resolution', None), - 'skip_confirmation': getattr(args, 'skip_confirmation', False), - 'modlist_value': getattr(args, 'modlist_value', None), - 'modlist_source': getattr(args, 'modlist_source', None), + "modlist_name": getattr(args, "modlist_name", None), + "install_dir": getattr(args, "install_dir", None), + "download_dir": getattr(args, "download_dir", None), + "nexus_api_key": getattr(args, "nexus_api_key", os.environ.get("NEXUS_API_KEY")), + "mo2_exe_path": getattr(args, "mo2_exe_path", None), + "resolution": getattr(args, "resolution", None), + "skip_confirmation": getattr(args, "skip_confirmation", False), + "modlist_value": getattr(args, "modlist_value", None), + "modlist_source": getattr(args, "modlist_source", None), } - + def _execute_legacy_configuration(self, context: dict): - """Execute configuration using legacy implementation. - - This is a temporary bridge - will be replaced with backend service calls. - - Args: - context: Configuration context dictionary - - Returns: - Result from legacy configuration - """ - # Import backend services from jackify.backend.handlers.menu_handler import ModlistMenuHandler from jackify.backend.handlers.config_handler import ConfigHandler - - # Create legacy handler instances + config_handler = ConfigHandler() modlist_menu = ModlistMenuHandler( config_handler=config_handler, - test_mode=self.test_mode + test_mode=self.test_mode, ) - - # Execute legacy configuration workflow - # The _configure_new_modlist method already handles Steam restart, manual steps, and configuration - result = modlist_menu._configure_new_modlist( - default_modlist_dir=context['install_dir'], - default_modlist_name=context['modlist_name'] + return modlist_menu._configure_new_modlist( + default_modlist_dir=context["install_dir"], + default_modlist_name=context["modlist_name"], ) - - # The _configure_new_modlist method already calls run_modlist_configuration_phase internally - # So we don't need to call it again here - - return result diff --git a/jackify/frontends/cli/commands/list_installed.py b/jackify/frontends/cli/commands/list_installed.py new file mode 100644 index 0000000..6239f0c --- /dev/null +++ b/jackify/frontends/cli/commands/list_installed.py @@ -0,0 +1,139 @@ +"""List installed Wine components for a configured modlist prefix.""" +import json +from pathlib import Path +from typing import Dict, List, Optional + +from jackify.shared.colors import ( + COLOR_ACTION, + COLOR_ERROR, + COLOR_INFO, + COLOR_PROMPT, + COLOR_RESET, + COLOR_SELECTION, + COLOR_WARNING, +) +from jackify.shared.ui_utils import clear_screen, print_jackify_banner, print_section_header + + +class ListInstalledCommand: + """Show deduplicated component records for a modlist prefix.""" + + def run(self, appid: Optional[str] = None) -> None: + clear_screen() + print_jackify_banner() + print_section_header("Installed Wine Components") + + if appid: + self._show_for_appid(appid, modlist_name=None) + else: + self._run_interactive() + + def _run_interactive(self) -> None: + from jackify.backend.handlers.modlist_handler import ModlistHandler + + print(f"{COLOR_INFO}Discovering configured modlists...{COLOR_RESET}") + try: + handler = ModlistHandler() + discovered = handler.discover_executable_shortcuts("ModOrganizer.exe") + shortcuts = [ + {"name": m.get("name", "Unknown"), "appid": str(m.get("appid", ""))} + for m in discovered + if m.get("appid") + ] + except Exception as exc: + print(f"{COLOR_ERROR}Failed to discover modlists: {exc}{COLOR_RESET}") + input("Press Enter to return to menu...") + return + + if not shortcuts: + print(f"{COLOR_WARNING}No configured modlists found.{COLOR_RESET}") + input("Press Enter to return to menu...") + return + + print() + for i, s in enumerate(shortcuts, 1): + print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} {s['name']}") + print(f" {COLOR_ACTION}AppID: {s['appid']}{COLOR_RESET}") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel") + + selection = input(f"\n{COLOR_PROMPT}Select modlist (0-{len(shortcuts)}): {COLOR_RESET}").strip() + if selection == "0" or not selection: + return + + try: + idx = int(selection) - 1 + if idx < 0 or idx >= len(shortcuts): + raise ValueError() + except ValueError: + print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}") + input("Press Enter to return to menu...") + return + + chosen = shortcuts[idx] + self._show_for_appid(chosen["appid"], modlist_name=chosen["name"]) + + def _show_for_appid(self, appid: str, modlist_name: Optional[str]) -> None: + from jackify.backend.handlers.path_handler import PathHandler + + compat = PathHandler.find_compat_data(appid) + if not compat: + print(f"{COLOR_ERROR}Prefix not found for AppID {appid}.{COLOR_RESET}") + input("Press Enter to continue...") + return + + pfx = compat / "pfx" + + wt_log = pfx / "winetricks.log" + wt_entries: List[str] = [] + if wt_log.is_file(): + seen: set = set() + for line in wt_log.read_text(errors="replace").splitlines(): + entry = line.strip() + if entry and entry not in seen: + seen.add(entry) + wt_entries.append(entry) + + jc_path = pfx / "jackify_components.json" + jc_data: Dict[str, dict] = {} + if jc_path.is_file(): + try: + jc_data = json.loads(jc_path.read_text(encoding="utf-8")) + except Exception: + pass + + label = modlist_name or appid + print(f"\n{COLOR_INFO}Installed components: {label}{COLOR_RESET}") + if modlist_name: + print(f"{COLOR_INFO}AppID: {appid}{COLOR_RESET}") + print(f"{COLOR_INFO}Prefix: {pfx}{COLOR_RESET}\n") + + if not wt_entries and not jc_data: + print(f"{COLOR_WARNING}No component records found.{COLOR_RESET}") + print(f"{COLOR_INFO}winetricks.log: {wt_log}{COLOR_RESET}") + input("\nPress Enter to continue...") + return + + all_components: List[str] = list(wt_entries) + for comp in jc_data: + if comp not in all_components: + all_components.append(comp) + + col_w = max((len(c) for c in all_components), default=20) + col_w = max(col_w, 20) + header = f"{'Component':<{col_w}} {'Method':<12} Timestamp" + print(f"{COLOR_SELECTION}{header}{COLOR_RESET}") + print("-" * (col_w + 30)) + + for comp in all_components: + if comp in jc_data: + method = jc_data[comp].get("method", "native") + ts = jc_data[comp].get("timestamp", "-") + else: + method = "winetricks" + ts = "-" + print(f"{comp:<{col_w}} {method:<12} {ts}") + + wt_note = "[OK]" if wt_log.is_file() else "[MISSING]" + jc_note = "[OK]" if jc_path.is_file() else "[MISSING]" + print(f"\n{COLOR_INFO}Sources: {wt_note} winetricks.log {jc_note} jackify_components.json{COLOR_RESET}") + input("\nPress Enter to continue...") diff --git a/jackify/frontends/cli/main.py b/jackify/frontends/cli/main.py index 76bf281..5ea5eb9 100755 --- a/jackify/frontends/cli/main.py +++ b/jackify/frontends/cli/main.py @@ -370,7 +370,7 @@ class JackifyCLI: # Now that we have args, configure logging properly self._configure_logging_final() - logger.debug('Initializing Jackify CLI Frontend') + logger.debug('Initialising Jackify CLI Frontend') logger.debug('JackifyCLI.run() called') logger.debug(f'Parsed args: {self.args}') @@ -438,13 +438,10 @@ class JackifyCLI: print(" Then select: Additional Tasks > Install Wabbajack") return 0 elif command == "install-mo2": - print("MO2 installation not yet implemented") - print("This functionality is coming soon!") - return 1 - elif command == "configure-nxm": - print("NXM configuration not yet implemented") - print("This functionality is coming soon!") - return 1 + from jackify.frontends.cli.commands.setup_mo2 import SetupMO2Command + SetupMO2Command().run() + return 0 + elif command == "recovery": return self._handle_legacy_recovery(args) elif command == "test-protontricks": @@ -468,6 +465,8 @@ class JackifyCLI: # HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY elif choice == "additional": self.menus['additional'].show_additional_tasks_menu(self) + elif choice == "tools_hub": + self.menus['additional']._execute_tools_hub(self) else: logger.warning(f"Invalid choice '{choice}' received from show_main_menu.") diff --git a/jackify/frontends/cli/menus/additional_menu.py b/jackify/frontends/cli/menus/additional_menu.py index b056a08..19990ae 100644 --- a/jackify/frontends/cli/menus/additional_menu.py +++ b/jackify/frontends/cli/menus/additional_menu.py @@ -29,33 +29,37 @@ class AdditionalMenuHandler: self._clear_screen() print_jackify_banner() print_section_header("Additional Tasks & Tools") - print(f"{COLOR_INFO}Nexus Authentication, TTW Install & more{COLOR_RESET}\n") + print(f"{COLOR_INFO}Modlist tools, diagnostics, and more{COLOR_RESET}\n") - print(f"{COLOR_SELECTION}1.{COLOR_RESET} Nexus Mods Authorization") - print(f" {COLOR_ACTION}→ Authorize with Nexus using OAuth or manage API key{COLOR_RESET}") - print(f"{COLOR_SELECTION}2.{COLOR_RESET} Tale of Two Wastelands (TTW) Installation") - print(f" {COLOR_ACTION}→ Install TTW using TTW_Linux_Installer{COLOR_RESET}") - print(f"{COLOR_SELECTION}3.{COLOR_RESET} Install Wabbajack Application") - print(f" {COLOR_ACTION}→ Downloads and configures the Wabbajack app itself (via Proton){COLOR_RESET}") - print(f"{COLOR_SELECTION}4.{COLOR_RESET} Setup Mod Organizer 2") - print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}") - print(f"{COLOR_SELECTION}5.{COLOR_RESET} Configure Tool Compatibility") + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Run Install Verifier") + print(f" {COLOR_ACTION}→ Check an installed modlist for common configuration problems{COLOR_RESET}") + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure Tool Compatibility") print(f" {COLOR_ACTION}→ Apply Wine registry settings for xEdit, Synthesis, Pandora, Nemesis{COLOR_RESET}") + print(f"{COLOR_SELECTION}3.{COLOR_RESET} Setup Mod Organizer 2") + print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}") + print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Wabbajack Application") + print(f" {COLOR_ACTION}→ Download the Wabbajack app under Proton — not needed for standard modlist installs{COLOR_RESET}") + print(f"{COLOR_SELECTION}5.{COLOR_RESET} Create Diagnostic Bundle") + print(f" {COLOR_ACTION}→ Package logs and system info for support{COLOR_RESET}") + print(f"{COLOR_SELECTION}6.{COLOR_RESET} Nexus Mods Authorization") + print(f" {COLOR_ACTION}→ Authorise with Nexus using OAuth or manage API key{COLOR_RESET}") print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") - selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-5): {COLOR_RESET}").strip() + selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-6): {COLOR_RESET}").strip() if selection.lower() == 'q': # Allow 'q' to re-display menu continue if selection == "1": - self._execute_nexus_authorization(cli_instance) + self._execute_run_verifier() elif selection == "2": - self._execute_ttw_install(cli_instance) - elif selection == "3": - self._execute_install_wabbajack(cli_instance) - elif selection == "4": - self._execute_setup_mo2(cli_instance) - elif selection == "5": self._execute_configure_tool_compat(cli_instance) + elif selection == "3": + self._execute_setup_mo2(cli_instance) + elif selection == "4": + self._execute_install_wabbajack(cli_instance) + elif selection == "5": + self._execute_diagnostic_bundle() + elif selection == "6": + self._execute_nexus_authorization(cli_instance) elif selection == "0": break else: @@ -271,17 +275,17 @@ class AdditionalMenuHandler: if authenticated: if method == 'oauth': - print(f"\n{COLOR_SUCCESS}Status: Authorized via OAuth{COLOR_RESET}") + print(f"\n{COLOR_SUCCESS}Status: Authorised via OAuth{COLOR_RESET}") if username: print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}") elif method == 'api_key': print(f"\n{COLOR_WARNING}Status: Using API Key (Legacy){COLOR_RESET}") print(f"{COLOR_INFO}Consider switching to OAuth for better security{COLOR_RESET}") else: - print(f"\n{COLOR_WARNING}Status: Not Authorized{COLOR_RESET}") + print(f"\n{COLOR_WARNING}Status: Not Authorised{COLOR_RESET}") print(f"{COLOR_INFO}You need to authorize to download mods from Nexus{COLOR_RESET}") - print(f"\n{COLOR_SELECTION}1.{COLOR_RESET} Authorize with Nexus (OAuth)") + print(f"\n{COLOR_SELECTION}1.{COLOR_RESET} Authorise with Nexus (OAuth)") print(f" {COLOR_ACTION}→ Opens browser for secure authorization{COLOR_RESET}") if method == 'oauth': @@ -320,7 +324,7 @@ class AdditionalMenuHandler: # Get username _, _, username = auth_service.get_auth_status() if username: - print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}") + print(f"{COLOR_INFO}Authorised as: {username}{COLOR_RESET}") else: print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}") print(f"{COLOR_INFO}You can try again or use API key as fallback.{COLOR_RESET}") @@ -474,3 +478,122 @@ class AdditionalMenuHandler: print(f"\n{COLOR_ERROR}Tool compatibility configuration failed. Check logs for details.{COLOR_RESET}") input("\nPress Enter to return to menu...") + + def _execute_diagnostic_bundle(self) -> None: + from jackify.backend.services.diagnostic_service import build_bundle + from jackify.shared.colors import COLOR_ERROR, COLOR_SUCCESS + + self._clear_screen() + print_jackify_banner() + print_section_header("Create Diagnostic Bundle") + print(f"{COLOR_INFO}Collecting logs, system info, and prefix records...{COLOR_RESET}\n") + + try: + bundle_path = build_bundle() + except Exception as exc: + print(f"{COLOR_ERROR}Failed to create bundle: {exc}{COLOR_RESET}") + input("Press Enter to return to menu...") + return + + print(f"{COLOR_SUCCESS}Bundle created:{COLOR_RESET} {bundle_path}") + print(f"\n{COLOR_INFO}Attach this file when reporting an issue.{COLOR_RESET}") + + input("\nPress Enter to return to menu...") + + def _execute_run_verifier(self) -> None: + from jackify.backend.services.install_verifier_service import ( + _load_verifier, run_install_verification, resolve_pfx_for_appid, + ) + from jackify.shared.colors import COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING + import threading + + self._clear_screen() + print_jackify_banner() + print_section_header("Run Install Verifier") + print(f"{COLOR_INFO}Discovering configured modlists...{COLOR_RESET}") + + try: + vmod = _load_verifier() + modlists = vmod.discover_installed_modlists() + except Exception as e: + print(f"{COLOR_ERROR}Failed to discover modlists: {e}{COLOR_RESET}") + input("Press Enter to return to menu...") + return + + if not modlists: + print(f"{COLOR_WARNING}No configured modlists found.{COLOR_RESET}") + print(f"{COLOR_INFO}Install and configure a modlist first.{COLOR_RESET}") + input("Press Enter to return to menu...") + return + + print() + for i, m in enumerate(modlists, 1): + print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} {m.get('name', 'Unknown')}") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel") + + selection = input(f"\n{COLOR_PROMPT}Select modlist (0-{len(modlists)}): {COLOR_RESET}").strip() + + if selection == "0" or not selection: + return + + try: + idx = int(selection) - 1 + if idx < 0 or idx >= len(modlists): + raise ValueError() + except ValueError: + print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}") + input("Press Enter to return to menu...") + return + + chosen = modlists[idx] + appid = chosen.get("appid", "") + modlist_dir = chosen.get("modlist_dir") + game_type = chosen.get("game_type", "unknown") + name = chosen.get("name", "Unknown") + + pfx = resolve_pfx_for_appid(appid) if appid else None + if not pfx or not pfx.is_dir(): + print(f"{COLOR_WARNING}No Wine prefix found for {name}. Cannot run verifier.{COLOR_RESET}") + input("Press Enter to return to menu...") + return + + print(f"\n{COLOR_INFO}Running install verification for: {name}{COLOR_RESET}") + print(f"{COLOR_INFO}This may take a moment...{COLOR_RESET}\n") + + try: + result_holder = [None] + def _worker(): + result_holder[0] = run_install_verification(pfx, modlist_dir, game_type, appid, name) + t = threading.Thread(target=_worker, daemon=True) + t.start() + t.join() + r = result_holder[0] + if r is None: + print(f"{COLOR_WARNING}Verifier returned no results.{COLOR_RESET}") + else: + passes = r.passes if hasattr(r, 'passes') else [] + warnings = r.warnings if hasattr(r, 'warnings') else [] + failures = r.failures if hasattr(r, 'failures') else [] + total = len(passes) + len(warnings) + len(failures) + print(f"--- Install Verification: {name} ---") + print(f" {len(passes)} passed, {len(warnings)} warnings, {len(failures)} failed (of {total} checks)\n") + for msg in failures: + print(f"{COLOR_ERROR} [FAIL] {msg}{COLOR_RESET}") + for msg in warnings: + print(f"{COLOR_WARNING} [WARN] {msg}{COLOR_RESET}") + if not failures and not warnings: + print(f"{COLOR_SUCCESS} All checks passed.{COLOR_RESET}") + if passes: + show_all = input(f"\n{COLOR_PROMPT}Show all {len(passes)} passed checks? (y/N): {COLOR_RESET}").strip().lower() + if show_all in ('y', 'yes'): + print() + for msg in passes: + print(f"{COLOR_DISABLED} [OK] {msg}{COLOR_RESET}") + except Exception as e: + print(f"{COLOR_ERROR}Verifier error: {e}{COLOR_RESET}") + + input("\nPress Enter to return to menu...") + + def _execute_tools_hub(self, cli_instance) -> None: + from jackify.frontends.cli.menus.tools_hub_menu import ToolsHubMenuHandler + ToolsHubMenuHandler().show_tools_hub_menu(cli_instance) diff --git a/jackify/frontends/cli/menus/main_menu.py b/jackify/frontends/cli/menus/main_menu.py index 3dbd2ea..518adf0 100644 --- a/jackify/frontends/cli/menus/main_menu.py +++ b/jackify/frontends/cli/menus/main_menu.py @@ -43,16 +43,20 @@ class MainMenuHandler: print(f"{COLOR_SELECTION}1.{COLOR_RESET} Modlist Tasks") print(f" {COLOR_ACTION}→ Install & Configure Modlists{COLOR_RESET}") print(f"{COLOR_SELECTION}2.{COLOR_RESET} Additional Tasks & Tools") - print(f" {COLOR_ACTION}→ Nexus OAuth, TTW Installation, Install Wabbajack{COLOR_RESET}") + print(f" {COLOR_ACTION}→ Verifier, Diagnostics, Nexus OAuth & more{COLOR_RESET}") + print(f"{COLOR_SELECTION}3.{COLOR_RESET} Tools Hub") + print(f" {COLOR_ACTION}→ Install, update, or switch engines and tools{COLOR_RESET}") print(f"{COLOR_SELECTION}0.{COLOR_RESET} Exit Jackify") - choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip() - + choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip() + if choice.lower() == 'q': # Allow 'q' to re-display menu continue if choice == "1": return "wabbajack" elif choice == "2": return "additional" + elif choice == "3": + return "tools_hub" elif choice == "0": return "exit" else: diff --git a/jackify/frontends/cli/menus/tools_hub_menu.py b/jackify/frontends/cli/menus/tools_hub_menu.py new file mode 100644 index 0000000..7b152ab --- /dev/null +++ b/jackify/frontends/cli/menus/tools_hub_menu.py @@ -0,0 +1,249 @@ +""" +Tools Hub Menu Handler for Jackify CLI Frontend +""" + +import logging +import threading +import time +from typing import Optional, Tuple + +from jackify.shared.colors import ( + COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, + COLOR_INFO, COLOR_DISABLED, COLOR_WARNING, COLOR_ERROR, COLOR_SUCCESS +) +from jackify.shared.ui_utils import print_jackify_banner, print_section_header, clear_screen +from jackify.frontends.cli.ui.indeterminate_status import CliIndeterminateStatus + +logger = logging.getLogger(__name__) + + +class ToolsHubMenuHandler: + """CLI menu for managing third-party tools via ToolRegistry.""" + + def show_tools_hub_menu(self, cli_instance) -> None: + from jackify.backend.services.tool_registry import ( + ToolRegistry, get_active_engine_id + ) + registry = ToolRegistry() + + while True: + clear_screen() + print_jackify_banner() + print_section_header("Third Party Tools Hub") + + statuses = [s for s in registry.get_all_statuses() if not s.definition.hidden] + active_engine_id = get_active_engine_id() + + print(f"{COLOR_INFO}Active engine: {active_engine_id}{COLOR_RESET}\n") + + for i, status in enumerate(statuses, 1): + defn = status.definition + if status.installed: + ver = status.installed_version or "unknown" + active_tag = ( + f" {COLOR_ACTION}[ACTIVE]{COLOR_RESET}" + if defn.is_engine and defn.tool_id == active_engine_id + else "" + ) + print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} {defn.display_name} " + f"{COLOR_INFO}v{ver}{COLOR_RESET}{active_tag}") + else: + print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} " + f"{COLOR_DISABLED}{defn.display_name} [not installed]{COLOR_RESET}") + print(f" {COLOR_ACTION}{defn.description}{COLOR_RESET}") + + print(f"\n{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") + selection = input( + f"\n{COLOR_PROMPT}Select tool (0-{len(statuses)}): {COLOR_RESET}" + ).strip() + + if selection == "0": + break + if selection.lower() == "q": + continue + + try: + idx = int(selection) - 1 + if idx < 0 or idx >= len(statuses): + raise ValueError() + except ValueError: + print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}") + time.sleep(1) + continue + + self._show_tool_detail(registry, statuses[idx], active_engine_id, cli_instance) + + def _show_tool_detail(self, registry, status, active_engine_id: str, cli_instance=None) -> None: + from jackify.backend.services.tool_registry import ( + get_active_engine_id, set_active_engine_id + ) + + defn = status.definition + + while True: + clear_screen() + print_jackify_banner() + print_section_header(defn.display_name) + print(f"{COLOR_INFO}{defn.description}{COLOR_RESET}\n") + + if status.installed: + ver = status.installed_version or "unknown" + print(f"Installed: {COLOR_INFO}{ver}{COLOR_RESET}") + if status.previous_version: + print(f"Previous: {COLOR_DISABLED}{status.previous_version}{COLOR_RESET}") + if defn.is_engine and defn.tool_id == active_engine_id: + print(f"Engine: {COLOR_ACTION}[ACTIVE]{COLOR_RESET}") + else: + print(f"Status: {COLOR_DISABLED}Not installed{COLOR_RESET}") + + latest = self._fetch_latest_with_spinner(registry, defn.tool_id) + + if latest: + latest_clean = latest.lstrip("v") + current_clean = (status.installed_version or "").lstrip("v") + update_available = status.installed and latest_clean != current_clean + update_tag = ( + f" {COLOR_WARNING}[update available]{COLOR_RESET}" if update_available + else f" {COLOR_ACTION}[up to date]{COLOR_RESET}" if status.installed + else "" + ) + print(f"Latest: {COLOR_INFO}{latest}{COLOR_RESET}{update_tag}") + else: + update_available = False + print(f"Latest: {COLOR_DISABLED}(could not check){COLOR_RESET}") + + options: dict = {} + opt_num = 1 + print() + + if not status.installed: + print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Install") + options[str(opt_num)] = "install" + opt_num += 1 + else: + if latest and update_available: + print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Update to {latest}") + options[str(opt_num)] = "update" + opt_num += 1 + if status.previous_version and defn.can_uninstall: + print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} " + f"Downgrade to {status.previous_version}") + options[str(opt_num)] = "downgrade" + opt_num += 1 + if defn.can_uninstall: + print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Uninstall") + options[str(opt_num)] = "uninstall" + opt_num += 1 + if defn.is_engine and defn.tool_id != active_engine_id: + print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Set as active engine") + options[str(opt_num)] = "set_active" + opt_num += 1 + if defn.tool_id == "ttw_installer": + print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Run TTW Installation") + options[str(opt_num)] = "run_ttw" + opt_num += 1 + + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Back") + + if not options: + input(f"\n{COLOR_PROMPT}Press Enter to go back...{COLOR_RESET}") + break + + selection = input(f"\n{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip() + + if selection == "0": + break + + action = options.get(selection) + if not action: + print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}") + time.sleep(1) + continue + + if action == "run_ttw": + from jackify.frontends.cli.menus.additional_menu import AdditionalMenuHandler + AdditionalMenuHandler()._execute_ttw_install(cli_instance) + status = registry.get_status(defn.tool_id) or status + continue + + if action == "set_active": + try: + set_active_engine_id(defn.tool_id) + active_engine_id = defn.tool_id + print(f"\n{COLOR_SUCCESS}Active engine set to {defn.display_name}.{COLOR_RESET}") + except Exception as e: + print(f"\n{COLOR_ERROR}Failed to set active engine: {e}{COLOR_RESET}") + input(f"{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + status = registry.get_status(defn.tool_id) or status + continue + + if action == "uninstall": + confirm = input( + f"\n{COLOR_WARNING}Uninstall {defn.display_name}? (y/N): {COLOR_RESET}" + ).strip().lower() + if confirm not in ("y", "yes"): + print(f"{COLOR_INFO}Cancelled.{COLOR_RESET}") + time.sleep(0.8) + status = registry.get_status(defn.tool_id) or status + continue + + ok, msg = self._run_with_spinner(registry, action, defn.tool_id, defn.display_name) + + if ok: + print(f"\n{COLOR_SUCCESS}{msg}{COLOR_RESET}") + else: + print(f"\n{COLOR_ERROR}{msg}{COLOR_RESET}") + + input(f"{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + status = registry.get_status(defn.tool_id) or status + active_engine_id = get_active_engine_id() + + def _fetch_latest_with_spinner(self, registry, tool_id: str) -> Optional[str]: + result: list = [None] + spinner = CliIndeterminateStatus() + spinner.set("Checking latest version...") + + def _worker(): + try: + result[0] = registry.check_latest_version(tool_id) + except Exception: + result[0] = None + + thread = threading.Thread(target=_worker, daemon=True) + thread.start() + thread.join() + spinner.stop() + return result[0] + + def _run_with_spinner( + self, registry, action: str, tool_id: str, display_name: str + ) -> Tuple[bool, str]: + labels = { + "install": f"Installing {display_name}", + "update": f"Updating {display_name}", + "downgrade": f"Downgrading {display_name}", + "uninstall": f"Uninstalling {display_name}", + } + label = labels.get(action, f"Working on {display_name}") + result: list = [False, ""] + spinner = CliIndeterminateStatus() + spinner.set(label) + + def _worker(): + try: + if action == "install": + result[0], result[1] = registry.install(tool_id) + elif action == "update": + result[0], result[1] = registry.update(tool_id) + elif action == "downgrade": + result[0], result[1] = registry.downgrade(tool_id) + elif action == "uninstall": + result[0], result[1] = registry.uninstall(tool_id) + except Exception as e: + result[0], result[1] = False, str(e) + + thread = threading.Thread(target=_worker, daemon=True) + thread.start() + thread.join() + spinner.stop() + return result[0], result[1] diff --git a/jackify/frontends/cli/menus/wabbajack_menu.py b/jackify/frontends/cli/menus/wabbajack_menu.py index 9053667..15c1126 100644 --- a/jackify/frontends/cli/menus/wabbajack_menu.py +++ b/jackify/frontends/cli/menus/wabbajack_menu.py @@ -31,7 +31,7 @@ class WabbajackMenuHandler: # Use print_section_header for consistency print_section_header("Modlist and Wabbajack Tasks") - print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install a Modlist (Automated)") + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install a Modlist") print(f" {COLOR_ACTION}→ Install a modlist in full: Select from a list or provide a .wabbajack file{COLOR_RESET}") print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure New Modlist (Post-Download)") print(f" {COLOR_ACTION}→ Modlist already downloaded? Configure and add to Steam{COLOR_RESET}") diff --git a/jackify/frontends/gui/__main__.py b/jackify/frontends/gui/__main__.py index f840538..d484876 100644 --- a/jackify/frontends/gui/__main__.py +++ b/jackify/frontends/gui/__main__.py @@ -10,6 +10,10 @@ from pathlib import Path def main(): + if len(sys.argv) > 1 and sys.argv[1].startswith('nxm://'): + handle_nxm_url(sys.argv[1]) + return + # Check if launched with jackify:// protocol URL (OAuth callback) if len(sys.argv) > 1 and sys.argv[1].startswith('jackify://'): handle_protocol_url(sys.argv[1]) @@ -20,6 +24,42 @@ def main(): gui_main() +def handle_nxm_url(url: str) -> None: + """Handle an nxm:// launch: hand off to running instance or open the app.""" + from PySide6.QtWidgets import QApplication + app = QApplication.instance() or QApplication(sys.argv) + + from jackify.backend.services.nxm_ipc import send_to_running_instance + if send_to_running_instance(url): + return + + # No running instance - check for installed modlists before launching + try: + from jackify.tools.verify_install import discover_installed_modlists + modlists = discover_installed_modlists() + except Exception: + modlists = [] + + if not modlists: + from jackify.frontends.gui.dialogs.nxm_download_dialog import show_no_modlists_error + show_no_modlists_error() + return + + from jackify.backend.services.nxm_url import parse_nxm_url + from jackify.backend.services import nxm_session + from jackify.frontends.gui.dialogs.nxm_download_dialog import NxmDownloadDialog + + nxm = parse_nxm_url(url) + auto_start = nxm_session.detect_active_mo2_modlist(modlists) + if auto_start is None: + remembered = nxm_session.get_remembered_modlist() + auto_start = next((m for m in modlists if m["name"] == remembered), None) if remembered else None + + dlg = NxmDownloadDialog(nxm, modlists, auto_start_modlist=auto_start) + dlg.show() + app.exec() + + def handle_protocol_url(url: str): """Handle jackify:// protocol URL (OAuth callback).""" from urllib.parse import urlparse, parse_qs diff --git a/jackify/frontends/gui/dialogs/__init__.py b/jackify/frontends/gui/dialogs/__init__.py index 773e549..42aff99 100644 --- a/jackify/frontends/gui/dialogs/__init__.py +++ b/jackify/frontends/gui/dialogs/__init__.py @@ -6,5 +6,6 @@ Custom dialogs for the Jackify GUI application. from .completion_dialog import NextStepsDialog from .success_dialog import SuccessDialog +from .verification_results_dialog import VerificationResultsDialog -__all__ = ['NextStepsDialog', 'SuccessDialog'] \ No newline at end of file +__all__ = ['NextStepsDialog', 'SuccessDialog', 'VerificationResultsDialog'] \ No newline at end of file diff --git a/jackify/frontends/gui/dialogs/completion_dialog.py b/jackify/frontends/gui/dialogs/completion_dialog.py index 97bf9d4..9383b3b 100644 --- a/jackify/frontends/gui/dialogs/completion_dialog.py +++ b/jackify/frontends/gui/dialogs/completion_dialog.py @@ -112,8 +112,8 @@ class NextStepsDialog(QDialog): content_text.setReadOnly(True) content_text.setStyleSheet( "QTextEdit { " - " background-color: #f8f9fa; " - " border: 1px solid #dee2e6; " + " background-color: #2a2a2a; " + " border: 1px solid #3a3a3a; " " border-radius: 6px; " " padding: 12px; " " font-family: 'Segoe UI', Arial, sans-serif; " @@ -139,7 +139,7 @@ class NextStepsDialog(QDialog): return_btn.clicked.connect(self.accept) # This will close dialog and return to menu return_btn.setStyleSheet( "QPushButton { " - " background-color: #3498db; " + " background-color: #1a5fa8; " " color: white; " " border: none; " " border-radius: 4px; " @@ -147,10 +147,10 @@ class NextStepsDialog(QDialog): " padding: 8px 16px; " "} " "QPushButton:hover { " - " background-color: #2980b9; " + " background-color: #2470b0; " "} " "QPushButton:pressed { " - " background-color: #21618c; " + " background-color: #3fd0ea; " "}" ) button_layout.addWidget(return_btn) @@ -163,7 +163,7 @@ class NextStepsDialog(QDialog): exit_btn.clicked.connect(self.reject) # This will close dialog and potentially exit app exit_btn.setStyleSheet( "QPushButton { " - " background-color: #95a5a6; " + " background-color: #4a5568; " " color: white; " " border: none; " " border-radius: 4px; " @@ -171,10 +171,10 @@ class NextStepsDialog(QDialog): " padding: 8px 16px; " "} " "QPushButton:hover { " - " background-color: #7f8c8d; " + " background-color: #5a6578; " "} " "QPushButton:pressed { " - " background-color: #6c7b7d; " + " background-color: #3fd0ea; " "}" ) button_layout.addWidget(exit_btn) @@ -194,15 +194,12 @@ class NextStepsDialog(QDialog): completion_title = "Modlist Configuration complete!" if is_existing else "Modlist Install and Configuration complete!" completion_log = "Configure_Existing_Modlist_workflow.log" if is_existing else "Configure_New_Modlist_workflow.log" - completion_text = f"""✓ Configuration completed successfully! + completion_text = f"""Configuration completed successfully! {completion_title} - • You should now be able to Launch '{self.modlist_name}' through Steam. - • Congratulations and enjoy the game! - -NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of -Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10). + - You should now be able to Launch '{self.modlist_name}' through Steam. + - Congratulations and enjoy the game! Detailed log available at: {get_jackify_logs_dir()}/{completion_log}""" diff --git a/jackify/frontends/gui/dialogs/enb_proton_dialog.py b/jackify/frontends/gui/dialogs/enb_proton_dialog.py index 60f9db0..9b0e54c 100644 --- a/jackify/frontends/gui/dialogs/enb_proton_dialog.py +++ b/jackify/frontends/gui/dialogs/enb_proton_dialog.py @@ -55,7 +55,7 @@ class ENBProtonDialog(QDialog): "QFrame#enbCard { " " background: #23272e; " " border-radius: 12px; " - " border: 2px solid #e67e22; " # Orange border for warning + " border: 2px solid #f0c040;" # Orange border for warning "}" ) card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding) @@ -67,17 +67,26 @@ class ENBProtonDialog(QDialog): "QLabel { " " font-size: 24px; " " font-weight: 700; " - " color: #e67e22; " # Orange warning color + " color: #f0c040;" # Orange warning color " margin-bottom: 4px; " "}" ) card_layout.addWidget(title_label) - # Main warning message - warning_text = ( - f"If you plan on using ENB as part of {self.modlist_name}, " - f"you will need to use one of the following Proton versions, otherwise you will have issues running the modlist:" - ) + from jackify.backend.data.modlist_proton_requirements import get_proton_requirement + _proton_req = get_proton_requirement(self.modlist_name) + + if _proton_req: + warning_text = ( + f"{self.modlist_name} " + f"requires a specific Proton version for ENB compatibility:" + ) + else: + warning_text = ( + f"If you plan on using ENB as part of {self.modlist_name}, " + f"you will need to use one of the following Proton versions, otherwise you will have issues running the modlist:" + ) + warning_label = QLabel(warning_text) warning_label.setAlignment(Qt.AlignCenter) warning_label.setWordWrap(True) @@ -93,17 +102,28 @@ class ENBProtonDialog(QDialog): warning_label.setTextFormat(Qt.RichText) card_layout.addWidget(warning_label) - # Proton version list (in order of recommendation) - versions_text = ( - "
" - "
(In order of recommendation)
" - "
" - "• Proton-CachyOS
" - "• GE-Proton 10-14 or lower
" - "• Proton 9 from Valve" - "
" - "
" - ) + if _proton_req: + versions_text = ( + "
" + f"
{_proton_req['required']}
" + f"
{_proton_req['note']}
" + "
" + ) + else: + versions_text = ( + "
" + "
(In order of recommendation)
" + "
" + "- Proton-CachyOS
" + "- GE-Proton
" + "- Proton 9 from Valve" + "
" + "
" + "Valve Proton 10 has known ENB compatibility issues." + "
" + "
" + ) + versions_label = QLabel(versions_text) versions_label.setAlignment(Qt.AlignLeft) versions_label.setWordWrap(True) @@ -118,26 +138,6 @@ class ENBProtonDialog(QDialog): versions_label.setTextFormat(Qt.RichText) card_layout.addWidget(versions_label) - # Additional note - note_text = ( - "
" - "Note: Valve's Proton 10 has known ENB compatibility issues." - "
" - ) - note_label = QLabel(note_text) - note_label.setAlignment(Qt.AlignCenter) - note_label.setWordWrap(True) - note_label.setStyleSheet( - "QLabel { " - " font-size: 12px; " - " color: #95a5a6; " - " font-style: italic; " - " margin-top: 8px; " - "}" - ) - note_label.setTextFormat(Qt.RichText) - card_layout.addWidget(note_label) - layout.addStretch() layout.addWidget(card, alignment=Qt.AlignCenter) layout.addSpacing(20) # Add spacing between card and button diff --git a/jackify/frontends/gui/dialogs/manual_download_dialog.py b/jackify/frontends/gui/dialogs/manual_download_dialog.py index 397b3d9..2d4d50d 100644 --- a/jackify/frontends/gui/dialogs/manual_download_dialog.py +++ b/jackify/frontends/gui/dialogs/manual_download_dialog.py @@ -36,12 +36,12 @@ _STATUS_LABELS = { _STATUS_COLOURS = { 'pending': '#808080', - 'browser_opened': '#3498db', - 'validating': '#f39c12', - 'complete': '#27ae60', - 'deferred': '#e67e22', - 'skipped': '#e67e22', - 'error': '#e74c3c', + 'browser_opened': '#3fd0ea', + 'validating': '#f0c040', + 'complete': '#3fd0ea', + 'deferred': '#f0c040', + 'skipped': '#888', + 'error': '#e05050', } # Column indices @@ -61,6 +61,15 @@ def _fmt_size(n: int) -> str: return f"{n:.1f} TB" +class _SizeTableItem(QTableWidgetItem): + """QTableWidgetItem that sorts by raw byte count stored in UserRole.""" + def __lt__(self, other: 'QTableWidgetItem') -> bool: + try: + return int(self.data(Qt.UserRole) or 0) < int(other.data(Qt.UserRole) or 0) + except (TypeError, ValueError): + return super().__lt__(other) + + class _Bridge(QObject): """Tiny bridge so worker-thread callbacks can update the Qt table safely.""" item_updated = Signal(object) # DownloadItem @@ -152,15 +161,17 @@ class ManualDownloadDialog(QDialog): # Batch-insert new rows with viewport updates suspended to avoid O(n²) repaints if new_items: + self._table.setSortingEnabled(False) self._table.setUpdatesEnabled(False) try: start_row = self._table.rowCount() self._table.setRowCount(start_row + len(new_items)) for i, item in enumerate(new_items): self._fill_row(start_row + i, item) - self._row_map[item.file_name] = start_row + i finally: self._table.setUpdatesEnabled(True) + self._table.setSortingEnabled(True) + self._rebuild_row_map() self._table.viewport().update() self._refresh_header() @@ -222,7 +233,9 @@ class ManualDownloadDialog(QDialog): self._table.setSelectionBehavior(QTableWidget.SelectRows) self._table.setEditTriggers(QTableWidget.NoEditTriggers) self._table.setAlternatingRowColors(True) + self._table.setSortingEnabled(True) self._table.verticalHeader().setVisible(False) + self._table.horizontalHeader().sectionClicked.connect(self._on_header_clicked) self._table.cellDoubleClicked.connect(self._on_row_double_clicked) self._table.setStyleSheet( "QTableWidget { background: #1a1d23; alternate-background-color: #1e2228; " @@ -284,6 +297,14 @@ class ManualDownloadDialog(QDialog): self._open_selected_btn.clicked.connect(self._on_open_selected) btn_row.addWidget(self._open_selected_btn) + self._scan_now_btn = QPushButton("Scan Now") + self._scan_now_btn.setToolTip( + "Re-scan the watch directory for already-downloaded files.\n" + "Use this if a file downloaded successfully but was not detected automatically." + ) + self._scan_now_btn.clicked.connect(self._on_scan_now) + btn_row.addWidget(self._scan_now_btn) + btn_row.addStretch() self._start_pause_btn = QPushButton("Start") @@ -308,9 +329,13 @@ class ManualDownloadDialog(QDialog): def _fill_row(self, row: int, item: DownloadItem) -> None: """Populate cells for a pre-allocated row (row must already exist in the table).""" from PySide6.QtGui import QColor - self._table.setItem(row, _COL_MOD, QTableWidgetItem(item.mod_name)) + mod_cell = QTableWidgetItem(item.mod_name) + mod_cell.setData(Qt.UserRole, item.file_name) # stable ID for row lookups after sort + self._table.setItem(row, _COL_MOD, mod_cell) self._table.setItem(row, _COL_NAME, QTableWidgetItem(item.file_name)) - self._table.setItem(row, _COL_SIZE, QTableWidgetItem(_fmt_size(item.expected_size))) + size_cell = _SizeTableItem(_fmt_size(item.expected_size)) + size_cell.setData(Qt.UserRole, item.expected_size) + self._table.setItem(row, _COL_SIZE, size_cell) colour = _STATUS_COLOURS.get(item.status, '#808080') status_cell = QTableWidgetItem(_STATUS_LABELS.get(item.status, item.status)) status_cell.setForeground(QColor(colour)) @@ -326,6 +351,26 @@ class ManualDownloadDialog(QDialog): status_cell.setForeground(QColor(_STATUS_COLOURS.get(item.status, '#808080'))) status_cell.setToolTip(item.error_message or "") + def _rebuild_row_map(self) -> None: + self._row_map.clear() + for row in range(self._table.rowCount()): + cell = self._table.item(row, _COL_MOD) + if cell: + file_name = cell.data(Qt.UserRole) + if file_name: + self._row_map[file_name] = row + + def _sync_pending_order_to_visual(self) -> None: + visual_order: dict[str, int] = {} + for row in range(self._table.rowCount()): + cell = self._table.item(row, _COL_MOD) + if cell: + file_name = cell.data(Qt.UserRole) + if file_name: + visual_order[file_name] = row + with self._manager._lock: + self._manager._items.sort(key=lambda i: visual_order.get(i.file_name, 999999)) + def _refresh_header(self) -> None: items = self._manager.items total = len(items) @@ -345,6 +390,10 @@ class ManualDownloadDialog(QDialog): # Slots # ------------------------------------------------------------------ + def _on_header_clicked(self, logical_index: int) -> None: + self._rebuild_row_map() + self._sync_pending_order_to_visual() + def _on_item_updated_slot(self, item: DownloadItem) -> None: row = self._row_map.get(item.file_name) if row is not None: @@ -369,7 +418,6 @@ class ManualDownloadDialog(QDialog): self._folder_label.setText(chosen) self._manager._watch_dir = self._watch_dir self._manager._watcher._config.watch_directory = self._watch_dir - self._manager._watcher._known = {} try: cfg = ConfigHandler() cfg.set("manual_download_watch_directory", str(self._watch_dir)) @@ -430,6 +478,16 @@ class ManualDownloadDialog(QDialog): return self._manager.reopen_item(file_name) + def _on_scan_now(self) -> None: + self._scan_now_btn.setEnabled(False) + self._scan_now_btn.setText("Scanning...") + self._manager.force_rescan() + from PySide6.QtCore import QTimer + QTimer.singleShot(2000, lambda: ( + self._scan_now_btn.setEnabled(True), + self._scan_now_btn.setText("Scan Now"), + )) + def _on_row_double_clicked(self, row: int, _column: int) -> None: file_item = self._table.item(row, _COL_NAME) if file_item is None: diff --git a/jackify/frontends/gui/dialogs/nxm_download_dialog.py b/jackify/frontends/gui/dialogs/nxm_download_dialog.py new file mode 100644 index 0000000..bc85900 --- /dev/null +++ b/jackify/frontends/gui/dialogs/nxm_download_dialog.py @@ -0,0 +1,470 @@ +"""NXM download dialog: modlist picker and download runner.""" + +import logging +from pathlib import Path +from typing import Optional, List, Dict, Tuple + +from PySide6.QtCore import QThread, Signal, Qt, QTimer +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QCheckBox, + QPushButton, + QProgressBar, + QFrame, +) + +from jackify.backend.services.nxm_url import NxmUrl +from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE +import jackify.backend.services.nxm_session as nxm_session + +logger = logging.getLogger(__name__) + +_BG_DARK = "#16191d" +_BG_CARD = "#1e2228" +_BG_LIST = "#1a1d23" +_BORDER = "#333" +_TEXT = "#d0d0d0" +_TEXT_DIM = "#8f98a3" +_TEXT_BRIGHT = "#e0e0e0" +_ERROR = "#cc4444" + + +class _ModNameFetchThread(QThread): + name_ready = Signal(str) + + def __init__(self, nxm: NxmUrl, parent=None): + super().__init__(parent) + self.nxm = nxm + + def run(self) -> None: + try: + from jackify.backend.services.nexus_auth_service import NexusAuthService + svc = NexusAuthService() + token = svc.get_auth_token() + method = svc.get_auth_method() or "api_key" + except Exception: + try: + from jackify.backend.services.api_key_service import APIKeyService + token = APIKeyService().get_saved_api_key() + method = "api_key" + except Exception: + return + + if not token: + return + + try: + import requests + if method == "oauth": + headers = {"Authorization": f"Bearer {token}", "User-Agent": "jackify"} + else: + headers = {"apikey": token, "User-Agent": "jackify"} + url = ( + f"https://api.nexusmods.com/v1/games/{self.nxm.game}" + f"/mods/{self.nxm.mod_id}.json" + ) + resp = requests.get(url, headers=headers, timeout=10) + resp.raise_for_status() + name = resp.json().get("name", "") + if name: + self.name_ready.emit(name) + except Exception as e: + logger.debug("Mod name fetch failed: %s", e) + + +class _DownloadThread(QThread): + progress = Signal(int, int) + finished = Signal(bool, str) + + def __init__( + self, + nxm: NxmUrl, + download_dir: Path, + auth_token: str, + auth_method: str, + parent=None, + ): + super().__init__(parent) + self.nxm = nxm + self.download_dir = download_dir + self.auth_token = auth_token + self.auth_method = auth_method + + def run(self) -> None: + from jackify.backend.services.nxm_downloader import ( + get_nxm_download_url, + download_nxm_file, + filename_from_cdn_url, + ) + + cdn_url = get_nxm_download_url(self.nxm, self.auth_token, self.auth_method) + if not cdn_url: + self.finished.emit(False, "Could not resolve download URL from Nexus API.") + return + + filename = filename_from_cdn_url( + cdn_url, f"mod_{self.nxm.mod_id}_file_{self.nxm.file_id}.zip" + ) + ok, msg = download_nxm_file(cdn_url, self.download_dir, filename, self._on_progress) + self.finished.emit(ok, msg) + + def _on_progress(self, downloaded: int, total: int) -> None: + self.progress.emit(downloaded, total) + + +class NxmDownloadDialog(QDialog): + """Modlist picker and download runner for incoming nxm:// links. + + When auto_start_modlist is provided the picker is hidden and the download + begins immediately - used when session memory has a remembered modlist. + """ + + def __init__( + self, + nxm: NxmUrl, + modlists: List[Dict], + parent=None, + auto_start_modlist: Optional[Dict] = None, + ): + super().__init__(parent) + self.nxm = nxm + self.modlists = modlists + self._thread: Optional[_DownloadThread] = None + self._name_thread: Optional[_ModNameFetchThread] = None + self._auto_start_modlist = auto_start_modlist + + self.setWindowTitle("NXM Download") + self.setMinimumWidth(520) + self.setModal(False) + self.setStyleSheet(f"QDialog {{ background: {_BG_DARK}; color: {_TEXT}; }}") + self._build_ui(show_picker=auto_start_modlist is None) + if auto_start_modlist is None: + self._apply_session_memory() + self._fetch_mod_name() + + # --- UI construction --- + + def _build_ui(self, show_picker: bool = True) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(10) + + layout.addWidget(self._make_header()) + + if show_picker: + layout.addWidget(self._make_section_label("Select modlist:")) + layout.addWidget(self._make_modlist_list()) + layout.addWidget(self._make_dest_label()) + layout.addWidget(self._make_session_row()) + else: + name = self._auto_start_modlist.get("name", "") + lbl = QLabel(f"Downloading to: {name}") + lbl.setTextFormat(Qt.RichText) + lbl.setStyleSheet(f"color: {_TEXT};") + layout.addWidget(lbl) + self._clear_session_btn = QPushButton("Change modlist (clear session)") + self._clear_session_btn.setFlat(True) + self._clear_session_btn.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};") + self._clear_session_btn.clicked.connect(self._on_clear_and_reopen) + layout.addWidget(self._clear_session_btn) + + layout.addWidget(self._make_progress_section()) + layout.addWidget(self._make_separator()) + layout.addLayout(self._make_button_row()) + + def _make_header(self) -> QFrame: + card = QFrame() + card.setStyleSheet( + f"QFrame {{ background: {_BG_CARD}; border-radius: 6px; border: 1px solid {_BORDER}; }}" + ) + vbox = QVBoxLayout(card) + vbox.setContentsMargins(12, 10, 12, 10) + vbox.setSpacing(4) + + self._title_label = QLabel(f"NXM Download: Mod {self.nxm.mod_id}") + self._title_label.setStyleSheet(f"color: {_TEXT_BRIGHT}; font-size: 13px; border: none;") + self._title_label.setTextFormat(Qt.RichText) + vbox.addWidget(self._title_label) + + detail = QLabel( + f"Game: {self.nxm.game}" + f"  |  Mod ID: {self.nxm.mod_id}" + f"  |  File ID: {self.nxm.file_id}" + ) + detail.setTextFormat(Qt.RichText) + detail.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px; border: none;") + vbox.addWidget(detail) + return card + + def _make_section_label(self, text: str) -> QLabel: + lbl = QLabel(text) + lbl.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px;") + return lbl + + def _make_modlist_list(self) -> QListWidget: + self._list = QListWidget() + self._list.setMaximumHeight(150) + self._list.setStyleSheet( + f"QListWidget {{ background: {_BG_LIST}; color: {_TEXT}; " + f"border: 1px solid {_BORDER}; border-radius: 4px; }}" + f"QListWidget::item:selected {{ background: #2a3a4a; color: {_TEXT_BRIGHT}; }}" + f"QListWidget::item:hover {{ background: #222830; }}" + ) + for ml in self.modlists: + item = QListWidgetItem(ml["name"]) + item.setData(Qt.UserRole, ml) + self._list.addItem(item) + self._list.currentItemChanged.connect(self._on_selection_changed) + return self._list + + def _make_dest_label(self) -> QLabel: + self._dest_label = QLabel("Download directory: (none)") + self._dest_label.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px;") + self._dest_label.setWordWrap(True) + return self._dest_label + + def _make_session_row(self) -> QFrame: + container = QFrame() + container.setStyleSheet("QFrame { border: none; }") + row = QHBoxLayout(container) + row.setContentsMargins(0, 0, 0, 0) + + self._remember_cb = QCheckBox("Remember for this session") + self._remember_cb.setStyleSheet(f"color: {_TEXT};") + row.addWidget(self._remember_cb) + row.addStretch() + + self._clear_btn = QPushButton("Clear remembered modlist") + self._clear_btn.setFlat(True) + self._clear_btn.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};") + self._clear_btn.clicked.connect(self._on_clear_session) + row.addWidget(self._clear_btn) + + self._update_clear_button() + return container + + def _make_progress_section(self) -> QFrame: + container = QFrame() + container.setStyleSheet("QFrame { border: none; }") + vbox = QVBoxLayout(container) + vbox.setContentsMargins(0, 0, 0, 0) + vbox.setSpacing(4) + + self._status_label = QLabel("") + self._status_label.setStyleSheet(f"color: {_TEXT_DIM};") + self._status_label.setVisible(False) + self._status_label.setWordWrap(True) + self._status_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + vbox.addWidget(self._status_label) + + self._progress_bar = QProgressBar() + self._progress_bar.setStyleSheet( + f"QProgressBar {{ border: 1px solid {_BORDER}; border-radius: 4px; " + f"background: #2c2c2c; height: 10px; color: transparent; }}" + f"QProgressBar::chunk {{ background: {JACKIFY_COLOR_BLUE}; border-radius: 3px; }}" + ) + self._progress_bar.setVisible(False) + vbox.addWidget(self._progress_bar) + return container + + def _make_button_row(self) -> QHBoxLayout: + row = QHBoxLayout() + row.addStretch() + + self._cancel_btn = QPushButton("Cancel") + self._cancel_btn.setStyleSheet( + f"QPushButton {{ background: #3a2020; color: {_TEXT}; border: 1px solid {_BORDER}; " + f"border-radius: 4px; padding: 5px 14px; }}" + f"QPushButton:hover {{ background: #5a2828; }}" + ) + self._cancel_btn.clicked.connect(self.reject) + row.addWidget(self._cancel_btn) + + self._download_btn = QPushButton("Download") + self._download_btn.setEnabled(False) + self._download_btn.setDefault(True) + self._download_btn.setStyleSheet( + f"QPushButton {{ background: {JACKIFY_COLOR_BLUE}; color: #000; font-weight: 600; " + f"border: none; border-radius: 4px; padding: 5px 18px; }}" + f"QPushButton:hover {{ background: #5ae0f5; }}" + f"QPushButton:disabled {{ background: #2a4a52; color: #555; }}" + ) + self._download_btn.clicked.connect(self._on_download) + row.addWidget(self._download_btn) + return row + + @staticmethod + def _make_separator() -> QFrame: + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setStyleSheet(f"color: {_BORDER};") + return sep + + # --- Session memory --- + + def showEvent(self, event) -> None: + super().showEvent(event) + if self._auto_start_modlist is not None: + QTimer.singleShot(0, self._start_auto_download) + + def _start_auto_download(self) -> None: + self._start_download_for(self._auto_start_modlist) + + def _on_clear_and_reopen(self) -> None: + nxm_session.clear_remembered_modlist() + self.reject() + dlg = NxmDownloadDialog(self.nxm, self.modlists, parent=self.parent()) + dlg.show() + + def _fetch_mod_name(self) -> None: + self._name_thread = _ModNameFetchThread(self.nxm, self) + self._name_thread.name_ready.connect(self._on_mod_name_ready) + self._name_thread.start() + + def _on_mod_name_ready(self, name: str) -> None: + self._title_label.setText(f"NXM Download: {name}") + self._name_thread = None + + def _apply_session_memory(self) -> None: + remembered = nxm_session.get_remembered_modlist() + if not remembered: + return + for i in range(self._list.count()): + if self._list.item(i).text() == remembered: + self._list.setCurrentRow(i) + self._remember_cb.setChecked(True) + break + + def _on_clear_session(self) -> None: + nxm_session.clear_remembered_modlist() + self._remember_cb.setChecked(False) + self._update_clear_button() + + def _update_clear_button(self) -> None: + self._clear_btn.setVisible(bool(nxm_session.get_remembered_modlist())) + + def _on_selection_changed(self) -> None: + item = self._list.currentItem() + self._download_btn.setEnabled(item is not None) + if item: + modlist = item.data(Qt.UserRole) + self._update_dest_label(modlist) + + def _update_dest_label(self, modlist: Dict) -> None: + from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir + modlist_dir = Path(modlist.get("modlist_dir", "")) + download_dir = resolve_mo2_download_dir(modlist_dir) + if download_dir: + self._dest_label.setText(f"Download directory: {download_dir}") + self._dest_label.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px;") + else: + self._dest_label.setText("Download directory: not configured - run Configure first") + self._dest_label.setStyleSheet(f"color: {_ERROR}; font-size: 11px;") + + # --- Download --- + + def _on_download(self) -> None: + item = self._list.currentItem() + if not item: + return + modlist = item.data(Qt.UserRole) + if self._remember_cb.isChecked(): + nxm_session.set_remembered_modlist(modlist["name"]) + self._update_clear_button() + self._start_download_for(modlist) + + def _start_download_for(self, modlist: Dict) -> None: + modlist_dir = Path(modlist.get("modlist_dir", "")) + + from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir + download_dir = resolve_mo2_download_dir(modlist_dir) + + if not download_dir or not download_dir.exists(): + self._set_status( + f"Download directory not found for {modlist['name']}. " + "Run Configure for this modlist first.", + error=True, + ) + return + + token, method = self._get_auth() + if not token: + self._set_status( + "Not logged in to Nexus. Please log in via Settings > Nexus Authentication.", + error=True, + ) + return + + self._set_downloading(True) + self._thread = _DownloadThread(self.nxm, download_dir, token, method, self) + self._thread.progress.connect(self._on_progress) + self._thread.finished.connect(self._on_download_finished) + self._thread.start() + + def _get_auth(self) -> Tuple[Optional[str], str]: + """Return (token, auth_method). auth_method is 'oauth' or 'api_key'.""" + try: + from jackify.backend.services.nexus_auth_service import NexusAuthService + svc = NexusAuthService() + token = svc.get_auth_token() + method = svc.get_auth_method() or "api_key" + if token: + return token, method + except Exception as e: + logger.warning("NexusAuthService unavailable: %s", e) + try: + from jackify.backend.services.api_key_service import APIKeyService + key = APIKeyService().get_saved_api_key() + if key: + return key, "api_key" + except Exception as e: + logger.warning("APIKeyService unavailable: %s", e) + return None, "api_key" + + def _on_progress(self, downloaded: int, total: int) -> None: + if total > 0: + self._progress_bar.setValue(int(downloaded * 100 / total)) + + def _on_download_finished(self, success: bool, message: str) -> None: + self._set_downloading(False) + self._thread = None + if success: + self._set_status("Download complete. MO2 will pick it up automatically.", error=False) + QTimer.singleShot(3000, self.accept) + else: + self._set_status(f"Download failed: {message}", error=True) + + def _set_downloading(self, active: bool) -> None: + self._download_btn.setEnabled(not active) + self._cancel_btn.setEnabled(not active) + if hasattr(self, "_list"): + self._list.setEnabled(not active) + self._progress_bar.setVisible(active) + self._progress_bar.setValue(0) + if active: + self._set_status("Downloading...", error=False) + + def _set_status(self, text: str, error: bool = False) -> None: + self._status_label.setText(text) + self._status_label.setVisible(bool(text)) + colour = _ERROR if error else JACKIFY_COLOR_BLUE + self._status_label.setStyleSheet(f"color: {colour};") + + +def show_no_modlists_error() -> None: + """Show a standalone error when no modlists are found.""" + from PySide6.QtWidgets import QMessageBox + msg = QMessageBox() + msg.setWindowTitle("Jackify - NXM Handler") + msg.setIcon(QMessageBox.Warning) + msg.setText( + "No installed modlists found.\n\n" + "NXM download handling requires at least one modlist that has been " + "installed and configured with Jackify." + ) + msg.exec() diff --git a/jackify/frontends/gui/dialogs/protontricks_error_dialog.py b/jackify/frontends/gui/dialogs/protontricks_error_dialog.py index 58ad285..b8f4f0b 100644 --- a/jackify/frontends/gui/dialogs/protontricks_error_dialog.py +++ b/jackify/frontends/gui/dialogs/protontricks_error_dialog.py @@ -62,7 +62,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): "QFrame#protontricksCard { " " background: #2d2323; " " border-radius: 12px; " - " border: 2px solid #e74c3c; " + " border: 2px solid #8b2020; " "}" ) @@ -74,7 +74,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): "QLabel { " " font-size: 36px; " " font-weight: bold; " - " color: #e74c3c; " + " color: #f0c040; " " margin-bottom: 4px; " "}" ) @@ -87,7 +87,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): "QLabel { " " font-size: 20px; " " font-weight: 600; " - " color: #e74c3c; " + " color: #f0c040; " " margin-bottom: 2px; " "}" ) @@ -126,7 +126,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): " text-align: center; " "} " "QProgressBar::chunk { " - " background-color: #4fc3f7; " + " background-color: #3fd0ea; " " border-radius: 3px; " "}" ) @@ -139,7 +139,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): self.status_label.setStyleSheet( "QLabel { " " font-size: 14px; " - " color: #4fc3f7; " + " color: #3fd0ea; " " margin: 8px 0; " "}" ) @@ -155,7 +155,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): self.flatpak_btn.clicked.connect(self._install_flatpak) self.flatpak_btn.setStyleSheet( "QPushButton { " - " background-color: #4fc3f7; " + " background-color: #1a5fa8; " " color: white; " " border: none; " " border-radius: 6px; " @@ -164,10 +164,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): " padding: 8px 16px; " "} " "QPushButton:hover { " - " background-color: #3498db; " + " background-color: #2470b0; " "} " "QPushButton:pressed { " - " background-color: #2980b9; " + " background-color: #3fd0ea; " "} " "QPushButton:disabled { " " background-color: #555; " @@ -182,7 +182,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): self.native_btn.clicked.connect(self._show_native_guidance) self.native_btn.setStyleSheet( "QPushButton { " - " background-color: #95a5a6; " + " background-color: #4a5568; " " color: white; " " border: none; " " border-radius: 6px; " @@ -191,10 +191,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): " padding: 8px 16px; " "} " "QPushButton:hover { " - " background-color: #7f8c8d; " + " background-color: #5a6578; " "} " "QPushButton:pressed { " - " background-color: #6c7b7d; " + " background-color: #3fd0ea; " "}" ) button_layout.addWidget(self.native_btn) @@ -211,7 +211,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): self.redetect_btn.clicked.connect(self._redetect) self.redetect_btn.setStyleSheet( "QPushButton { " - " background-color: #27ae60; " + " background-color: #4a5568; " " color: white; " " border: none; " " border-radius: 4px; " @@ -219,10 +219,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): " padding: 8px 16px; " "} " "QPushButton:hover { " - " background-color: #229954; " + " background-color: #5a6578; " "} " "QPushButton:pressed { " - " background-color: #1e8449; " + " background-color: #3fd0ea; " "}" ) bottom_layout.addWidget(self.redetect_btn) @@ -235,7 +235,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): exit_btn.clicked.connect(self._exit_app) exit_btn.setStyleSheet( "QPushButton { " - " background-color: #e74c3c; " + " background-color: #8b2020; " " color: white; " " border: none; " " border-radius: 4px; " @@ -243,10 +243,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): " padding: 8px 16px; " "} " "QPushButton:hover { " - " background-color: #c0392b; " + " background-color: #a02828; " "} " "QPushButton:pressed { " - " background-color: #a93226; " + " background-color: #7a1a1a; " "}" ) bottom_layout.addWidget(exit_btn) @@ -287,12 +287,12 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): if success: self.status_label.setText("✓ Installation successful!") - self.status_label.setStyleSheet("QLabel { color: #27ae60; font-size: 14px; margin: 8px 0; }") + self.status_label.setStyleSheet("QLabel { color: #3fd0ea; font-size: 14px; margin: 8px 0; }") # Auto-redetect after successful installation self._redetect() else: self.status_label.setText(f"✗ Installation failed: {message}") - self.status_label.setStyleSheet("QLabel { color: #e74c3c; font-size: 14px; margin: 8px 0; }") + self.status_label.setStyleSheet("QLabel { color: #e05050; font-size: 14px; margin: 8px 0; }") def _show_native_guidance(self): """Show native installation guidance""" @@ -307,12 +307,12 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): if is_installed: self.status_label.setText("✓ Protontricks found!") - self.status_label.setStyleSheet("QLabel { color: #27ae60; font-size: 14px; margin: 8px 0; }") + self.status_label.setStyleSheet("QLabel { color: #3fd0ea; font-size: 14px; margin: 8px 0; }") self.status_label.setVisible(True) self.accept() # Close dialog successfully else: self.status_label.setText("✗ Protontricks still not found") - self.status_label.setStyleSheet("QLabel { color: #e74c3c; font-size: 14px; margin: 8px 0; }") + self.status_label.setStyleSheet("QLabel { color: #e05050; font-size: 14px; margin: 8px 0; }") self.status_label.setVisible(True) def _exit_app(self): diff --git a/jackify/frontends/gui/dialogs/settings_dialog.py b/jackify/frontends/gui/dialogs/settings_dialog.py index 036f86f..8cd0a0a 100644 --- a/jackify/frontends/gui/dialogs/settings_dialog.py +++ b/jackify/frontends/gui/dialogs/settings_dialog.py @@ -86,7 +86,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog self.api_show_btn.setText("\U0001F441") if checked: self.api_key_edit.setEchoMode(QLineEdit.Normal) - self.api_show_btn.setStyleSheet("QToolButton { color: #4fc3f7; }") + self.api_show_btn.setStyleSheet("QToolButton { color: #3fd0ea; }") else: self.api_key_edit.setEchoMode(QLineEdit.Password) self.api_show_btn.setStyleSheet("") @@ -122,8 +122,8 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog self.config_handler.clear_api_key() MessageService.information(self, "API Key Cleared", "Nexus API Key has been cleared.", safety_level="low") - def _on_api_key_changed(self, text): - api_key = text.strip() + def _on_api_key_changed(self): + api_key = self.api_key_edit.text().strip() self.config_handler.save_api_key(api_key) @@ -314,6 +314,9 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog # Save auto tool compat preference self.config_handler.set('auto_tool_compat', self.auto_tool_compat_checkbox.isChecked()) self.config_handler.set('force_github_updates', self.force_github_updates_checkbox.isChecked()) + if getattr(self, 'clf3_default_checkbox', None): + from jackify.backend.services.tool_registry import set_active_engine_id + set_active_engine_id("clf3" if self.clf3_default_checkbox.isChecked() else "jackify-engine") # Save component installation method preference if self.winetricks_radio.isChecked(): diff --git a/jackify/frontends/gui/dialogs/settings_dialog_tabs.py b/jackify/frontends/gui/dialogs/settings_dialog_tabs.py index 2a07ede..2ed760f 100644 --- a/jackify/frontends/gui/dialogs/settings_dialog_tabs.py +++ b/jackify/frontends/gui/dialogs/settings_dialog_tabs.py @@ -153,7 +153,7 @@ class SettingsDialogTabsMixin: api_key = self.config_handler.get_api_key() self.api_key_edit.setText(api_key if api_key else "") self.api_key_edit.setToolTip("Your Nexus API Key (legacy authentication method)") - self.api_key_edit.textChanged.connect(self._on_api_key_changed) + self.api_key_edit.editingFinished.connect(self._on_api_key_changed) self.api_show_btn = QToolButton() self.api_show_btn.setCheckable(True) self.api_show_btn.setIcon(QIcon.fromTheme("view-visible")) @@ -297,6 +297,20 @@ class SettingsDialogTabsMixin: self.force_github_updates_checkbox.setStyleSheet("color: #fff;") component_layout.addWidget(self.force_github_updates_checkbox) + self.clf3_default_checkbox = QCheckBox("Use CLF3 as default install engine") + self.clf3_default_checkbox.setToolTip( + "Use CLF3 (SulfurNitride) as the default engine for all installs. " + "CLF3 will be downloaded automatically if not already installed. " + "You can still override this per-install on the Install screen." + ) + self.clf3_default_checkbox.setStyleSheet("color: #fff;") + try: + from jackify.backend.services.tool_registry import get_active_engine_id + self.clf3_default_checkbox.setChecked(get_active_engine_id() == "clf3") + except Exception: + pass + component_layout.addWidget(self.clf3_default_checkbox) + advanced_layout.addWidget(component_group) advanced_layout.addStretch() self.tab_widget.addTab(advanced_tab, "Advanced") diff --git a/jackify/frontends/gui/dialogs/success_dialog.py b/jackify/frontends/gui/dialogs/success_dialog.py index d261340..92dce54 100644 --- a/jackify/frontends/gui/dialogs/success_dialog.py +++ b/jackify/frontends/gui/dialogs/success_dialog.py @@ -31,22 +31,32 @@ class SuccessDialog(QDialog): - Return and Exit buttons """ - def __init__(self, modlist_name: str, workflow_type: str, time_taken: str, game_name: str = None, parent=None): + def __init__( + self, + modlist_name: str, + workflow_type: str, + time_taken: str, + game_name: str = None, + verification_results=None, + parent=None, + ): super().__init__(parent) self.modlist_name = modlist_name self.workflow_type = workflow_type self.time_taken = time_taken self.game_name = game_name - self.setWindowTitle("Success!") + self.verification_results = verification_results + self.setWindowTitle("Complete" if (verification_results and verification_results.failures) else "Success!") self.setWindowModality(Qt.NonModal) self.setAttribute(Qt.WA_ShowWithoutActivating, True) self.setAttribute(Qt.WA_DeleteOnClose, True) - self.setFixedSize(500, 500) + self.setFixedWidth(500) + self.setMinimumHeight(400) self.setWindowFlag(Qt.WindowDoesNotAcceptFocus, True) self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }" ) layout = QVBoxLayout(self) layout.setSpacing(0) - layout.setContentsMargins(30, 20, 30, 20) # Reduced top/bottom margins to prevent truncation + layout.setContentsMargins(20, 20, 20, 20) # --- Card background for content --- card = QFrame(self) @@ -69,22 +79,36 @@ class SuccessDialog(QDialog): ) card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding) - # Success title (less saturated green) - title_label = QLabel("Success!") + has_verify_failures = bool( + self.verification_results and self.verification_results.failures + ) + + title_text = "Complete" if has_verify_failures else "Success!" + title_color = "#f0c040" if has_verify_failures else "#3fd0ea" + title_label = QLabel(title_text) title_label.setAlignment(Qt.AlignCenter) title_label.setStyleSheet( - "QLabel { " - " font-size: 22px; " - " font-weight: 600; " - " color: #2ecc71; " - " margin-bottom: 2px; " - "}" + f"QLabel {{ " + f" font-size: 22px; " + f" font-weight: 600; " + f" color: {title_color}; " + f" margin-bottom: 2px; " + f"}}" ) card_layout.addWidget(title_label) - # Personalized success message (modlist name in Jackify Blue, but less bold) + # Personalized message (modlist name in Jackify Blue, but less bold) modlist_name_html = f'{self.modlist_name}' - if self.workflow_type == "install": + if has_verify_failures: + suffix_map = { + "install": "installed with issues - review verification results.", + "update": "updated with issues - review verification results.", + "configure_new": "configured with issues - review verification results.", + "configure_existing": "configuration updated with issues - review verification results.", + "tool_config": "tool compatibility configured with issues - review verification results.", + } + suffix_text = suffix_map.get(self.workflow_type, "completed with issues - review verification results.") + elif self.workflow_type == "install": suffix_text = "installed successfully!" elif self.workflow_type == "update": suffix_text = "updated successfully!" @@ -95,7 +119,6 @@ class SuccessDialog(QDialog): elif self.workflow_type == "tool_config": suffix_text = "tool compatibility configured successfully!" else: - # Fallback for other workflow types message_text = self._build_success_message() suffix_text = message_text.replace(self.modlist_name, "").strip() @@ -153,12 +176,16 @@ class SuccessDialog(QDialog): ) card_layout.addWidget(next_steps_label) + # Verification results summary + if self.verification_results is not None: + self._add_verification_section(card_layout) + # Subtle Ko-Fi support link - kofi_label = QLabel('Enjoying Jackify? Support development ♥') + kofi_label = QLabel('Enjoying Jackify? Support development ♥') kofi_label.setAlignment(Qt.AlignCenter) kofi_label.setStyleSheet( "QLabel { " - " color: #72A5F2; " + " color: #3fd0ea; " " font-size: 11px; " " margin-top: 8px; " " padding: 4px; " @@ -275,6 +302,94 @@ class SuccessDialog(QDialog): # ENB Proton warning shown in separate dialog return base_message + def _add_verification_section(self, card_layout): + """Add a verification summary section to the card layout.""" + from PySide6.QtWidgets import QScrollArea + + r = self.verification_results + n_pass = len(r.passes) + n_warn = len(r.warnings) + n_fail = len(r.failures) + + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setStyleSheet("color: #353a40;") + card_layout.addWidget(sep) + + if n_fail: + summary_text = f"[FAIL] Verification: {n_fail} failure(s), {n_warn} warning(s)" + summary_color = "#e05050" + elif n_warn: + summary_text = f"[WARN] Verification: {n_warn} warning(s) - review before playing" + summary_color = "#f0c040" + else: + summary_text = f"[OK] Verification passed ({n_pass} checks)" + summary_color = "#3fd0ea" + + summary_row = QHBoxLayout() + summary_row.setContentsMargins(0, 0, 0, 0) + summary_row.setSpacing(8) + + summary_lbl = QLabel(summary_text) + summary_lbl.setAlignment(Qt.AlignCenter) + summary_lbl.setWordWrap(True) + summary_lbl.setStyleSheet( + f"QLabel {{ font-size: 12px; font-weight: bold; color: {summary_color}; }}" + ) + summary_row.addStretch() + summary_row.addWidget(summary_lbl) + + view_btn = QPushButton("View checks") + view_btn.setFixedWidth(90) + view_btn.setStyleSheet( + "QPushButton { font-size: 11px; color: #ccc; background: #3a3a3a; " + "border: 1px solid #555; border-radius: 4px; padding: 3px 6px; }" + "QPushButton:hover { background: #4a4a4a; color: #fff; }" + ) + view_btn.setCursor(Qt.PointingHandCursor) + view_btn.clicked.connect(lambda: self._show_verification_detail()) + summary_row.addWidget(view_btn) + summary_row.addStretch() + + row_widget = QWidget() + row_widget.setLayout(summary_row) + card_layout.addWidget(row_widget) + + if n_fail or n_warn: + detail_widget = QWidget() + detail_layout = QVBoxLayout(detail_widget) + detail_layout.setContentsMargins(0, 0, 0, 0) + detail_layout.setSpacing(2) + + for msg in r.failures: + lbl = QLabel(f"[FAIL] {msg}") + lbl.setWordWrap(True) + lbl.setStyleSheet("color: #e05050; font-size: 11px;") + detail_layout.addWidget(lbl) + for msg in r.warnings: + lbl = QLabel(f"[WARN] {msg}") + lbl.setWordWrap(True) + lbl.setStyleSheet("color: #f0c040; font-size: 11px;") + detail_layout.addWidget(lbl) + + scroll = QScrollArea() + scroll.setWidget(detail_widget) + scroll.setWidgetResizable(True) + scroll.setMaximumHeight(120) + scroll.setStyleSheet( + "QScrollArea { border: 1px solid #353a40; border-radius: 4px; background: #1a1d23; }" + ) + card_layout.addWidget(scroll) + + def _show_verification_detail(self): + """Open the full verification results dialog.""" + try: + from jackify.frontends.gui.dialogs.verification_results_dialog import VerificationResultsDialog + dlg = VerificationResultsDialog(self.verification_results, parent=self) + dlg.show() + except Exception as exc: + logger.error("Could not open verification dialog: %s", exc) + def _update_countdown(self): if self._countdown > 0: self.return_btn.setText(f"{self._orig_return_text} ({self._countdown}s)") diff --git a/jackify/frontends/gui/dialogs/ulimit_guidance_dialog.py b/jackify/frontends/gui/dialogs/ulimit_guidance_dialog.py index 91a2e68..cb8a78a 100644 --- a/jackify/frontends/gui/dialogs/ulimit_guidance_dialog.py +++ b/jackify/frontends/gui/dialogs/ulimit_guidance_dialog.py @@ -389,11 +389,13 @@ class UlimitGuidanceDialog(QDialog): """Apply dialog styling""" self.setStyleSheet(""" QDialog { - background-color: #f5f5f5; + background-color: #232323; + color: #e0e0e0; } QGroupBox { font-weight: bold; - border: 2px solid #cccccc; + color: #e0e0e0; + border: 2px solid #3a3a3a; border-radius: 5px; margin-top: 1ex; padding-top: 10px; @@ -404,13 +406,14 @@ class UlimitGuidanceDialog(QDialog): padding: 0 5px 0 5px; } QTextEdit { - background-color: #ffffff; - border: 1px solid #cccccc; + background-color: #2a2a2a; + color: #e0e0e0; + border: 1px solid #3a3a3a; border-radius: 3px; padding: 5px; } QPushButton { - background-color: #007acc; + background-color: #4a5568; color: white; border: none; padding: 8px 16px; @@ -418,14 +421,14 @@ class UlimitGuidanceDialog(QDialog): font-weight: bold; } QPushButton:hover { - background-color: #005a9e; + background-color: #5a6578; } QPushButton:pressed { - background-color: #004175; + background-color: #3fd0ea; } QPushButton:disabled { - background-color: #cccccc; - color: #666666; + background-color: #333; + color: #666; } """) diff --git a/jackify/frontends/gui/dialogs/verification_results_dialog.py b/jackify/frontends/gui/dialogs/verification_results_dialog.py new file mode 100644 index 0000000..4497715 --- /dev/null +++ b/jackify/frontends/gui/dialogs/verification_results_dialog.py @@ -0,0 +1,210 @@ +"""Verification results dialog shown after install/configure workflows.""" + +import logging + +from PySide6.QtCore import Qt +from PySide6.QtGui import QClipboard, QGuiApplication +from PySide6.QtWidgets import ( + QDialog, QHBoxLayout, QLabel, QPushButton, + QTextEdit, QVBoxLayout, +) + +logger = logging.getLogger(__name__) + +_COLOR_FAIL = "#e05050" +_COLOR_WARN = "#f0c040" +_COLOR_OK = "#3fd0ea" +_COLOR_DIM = "#888888" + + +def _html_row(prefix: str, color: str, msg: str) -> str: + safe = msg.replace("&", "&").replace("<", "<").replace(">", ">") + return ( + f'' + f'{prefix} {safe}' + f'
' + ) + + +class VerificationResultsDialog(QDialog): + """Shows the output of verify_install.py after a workflow completes.""" + + def __init__(self, results, parent=None): + super().__init__(parent) + self.setWindowTitle("Installation Verification") + self.setWindowModality(Qt.NonModal) + self.setAttribute(Qt.WA_DeleteOnClose, True) + self.setMinimumWidth(600) + + self._results = results + n_pass = len(results.passes) + n_warn = len(results.warnings) + n_fail = len(results.failures) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 16, 20, 16) + layout.setSpacing(10) + + # --- Headline --- + if n_fail: + headline = f"[FAIL] {n_fail} failure{'s' if n_fail != 1 else ''}, {n_warn} warning{'s' if n_warn != 1 else ''}, {n_pass} passed" + h_color = _COLOR_FAIL + elif n_warn: + headline = f"[WARN] {n_warn} warning{'s' if n_warn != 1 else ''}, {n_pass} passed" + h_color = _COLOR_WARN + else: + headline = f"[OK] All {n_pass} checks passed" + h_color = _COLOR_OK + + headline_label = QLabel(headline) + headline_label.setStyleSheet( + f"font-size: 14px; font-weight: bold; color: {h_color};" + ) + layout.addWidget(headline_label) + + # --- Issues view (always visible) --- + self._issues_view = QTextEdit() + self._issues_view.setReadOnly(True) + self._issues_view.setStyleSheet( + "QTextEdit { background: #1e1e1e; border: 1px solid #444; " + "border-radius: 4px; font-size: 12px; padding: 6px; }" + ) + self._issues_view.setHtml(self._build_issues_html(results)) + self._issues_view.setMinimumHeight(80) + self._issues_view.setMaximumHeight(220) + layout.addWidget(self._issues_view) + + # --- Full report view (hidden by default) --- + self._full_view = QTextEdit() + self._full_view.setReadOnly(True) + self._full_view.setStyleSheet( + "QTextEdit { background: #1a1a1a; border: 1px solid #333; " + "border-radius: 4px; font-size: 11px; padding: 6px; }" + ) + self._full_view.setHtml(self._build_full_html(results)) + self._full_view.setMinimumHeight(160) + self._full_view.setMaximumHeight(300) + self._full_view.setVisible(False) + layout.addWidget(self._full_view) + + # --- Button row --- + btn_row = QHBoxLayout() + + self._toggle_btn = QPushButton("Show all checks") + self._toggle_btn.setFixedWidth(130) + self._toggle_btn.setStyleSheet( + "QPushButton { font-size: 11px; color: #ccc; background: #3a3a3a; " + "border: 1px solid #555; border-radius: 4px; padding: 4px 8px; }" + "QPushButton:hover { background: #4a4a4a; color: #fff; }" + ) + self._toggle_btn.setCursor(Qt.PointingHandCursor) + self._toggle_btn.clicked.connect(self._toggle_full_report) + btn_row.addWidget(self._toggle_btn) + + btn_row.addStretch() + + copy_btn = QPushButton("Copy Report") + copy_btn.setFixedWidth(110) + copy_btn.setToolTip("Copy plain-text report to clipboard") + copy_btn.clicked.connect(self._copy_report) + btn_row.addWidget(copy_btn) + + close_btn = QPushButton("Close") + close_btn.setFixedWidth(80) + close_btn.clicked.connect(self.accept) + btn_row.addWidget(close_btn) + + layout.addLayout(btn_row) + + self.adjustSize() + + # ------------------------------------------------------------------ + + def _build_issues_html(self, results) -> str: + lines = [] + for msg in results.failures: + lines.append(_html_row("[FAIL]", _COLOR_FAIL, msg)) + for msg in results.warnings: + lines.append(_html_row("[WARN]", _COLOR_WARN, msg)) + if not results.failures and not results.warnings: + lines.append(_html_row("[OK]", _COLOR_OK, "No issues found. Your modlist is correctly configured.")) + return "".join(lines) + + def _build_full_html(self, results) -> str: + lines = [] + if results.failures: + lines.append(f'FAILURES
') + for msg in results.failures: + lines.append(_html_row("[FAIL]", _COLOR_FAIL, msg)) + lines.append("
") + if results.warnings: + lines.append(f'WARNINGS
') + for msg in results.warnings: + lines.append(_html_row("[WARN]", _COLOR_WARN, msg)) + lines.append("
") + if results.passes: + lines.append(f'PASSED
') + for msg in results.passes: + lines.append(_html_row("[OK] ", _COLOR_OK, msg)) + components = getattr(results, "installed_components", []) + if components: + lines.append("
") + lines.append(f'INSTALLED COMPONENTS ({len(components)})
') + for c in components: + method = c.get("method", "unknown") + method_color = _COLOR_OK if method == "native" else _COLOR_DIM + safe_name = c["name"].replace("&", "&").replace("<", "<") + lines.append( + f'' + f'{safe_name}' + f' ({method})' + f'
' + ) + return "".join(lines) + + def _build_plain_text(self) -> str: + r = self._results + n_pass = len(r.passes) + n_warn = len(r.warnings) + n_fail = len(r.failures) + + lines = ["Jackify - Installation Verification", "=" * 40] + if n_fail: + lines.append(f"RESULT: FAILED ({n_fail} failures, {n_warn} warnings, {n_pass} passed)") + elif n_warn: + lines.append(f"RESULT: WARNING ({n_warn} warnings, {n_pass} passed)") + else: + lines.append(f"RESULT: OK (all {n_pass} checks passed)") + lines.append("") + + if r.failures: + lines.append("FAILURES:") + for msg in r.failures: + lines.append(f" [FAIL] {msg}") + lines.append("") + if r.warnings: + lines.append("WARNINGS:") + for msg in r.warnings: + lines.append(f" [WARN] {msg}") + lines.append("") + if r.passes: + lines.append("PASSED:") + for msg in r.passes: + lines.append(f" [OK] {msg}") + components = getattr(r, "installed_components", []) + if components: + lines.append("") + lines.append("INSTALLED COMPONENTS:") + for c in components: + lines.append(f" {c['name']} ({c.get('method', 'unknown')})") + + return "\n".join(lines) + + def _toggle_full_report(self): + visible = self._full_view.isVisible() + self._full_view.setVisible(not visible) + self._toggle_btn.setText("Hide all checks" if not visible else "Show all checks") + self.adjustSize() + + def _copy_report(self): + QGuiApplication.clipboard().setText(self._build_plain_text()) diff --git a/jackify/frontends/gui/dialogs/warning_dialog.py b/jackify/frontends/gui/dialogs/warning_dialog.py index f2dba32..90116f0 100644 --- a/jackify/frontends/gui/dialogs/warning_dialog.py +++ b/jackify/frontends/gui/dialogs/warning_dialog.py @@ -49,7 +49,7 @@ class WarningDialog(QDialog): "QFrame#warningCard { " " background: #2d2323; " " border-radius: 12px; " - " border: 2px solid #e67e22; " + " border: 2px solid #f0c040; " "}" ) @@ -61,7 +61,7 @@ class WarningDialog(QDialog): "QLabel { " " font-size: 36px; " " font-weight: bold; " - " color: #e67e22; " + " color: #f0c040; " " margin-bottom: 4px; " "}" ) @@ -74,7 +74,7 @@ class WarningDialog(QDialog): "QLabel { " " font-size: 20px; " " font-weight: 600; " - " color: #e67e22; " + " color: #f0c040; " " margin-bottom: 2px; " "}" ) @@ -106,7 +106,7 @@ class WarningDialog(QDialog): self.confirm_label.setStyleSheet( "QLabel { " " font-size: 13px; " - " color: #e67e22; " + " color: #f0c040; " " margin-bottom: 2px; " "}" ) @@ -118,11 +118,11 @@ class WarningDialog(QDialog): self._default_lineedit_style = ( "QLineEdit { " " font-size: 15px; " - " border: 1px solid #e67e22; " + " border: 1px solid #f0c040; " " border-radius: 6px; " " padding: 6px; " " background: #23272e; " - " color: #e67e22; " + " color: #e0e0e0; " "}" ) self.confirm_edit.setStyleSheet(self._default_lineedit_style) @@ -140,7 +140,7 @@ class WarningDialog(QDialog): cancel_btn.clicked.connect(self.reject) cancel_btn.setStyleSheet( "QPushButton { " - " background-color: #95a5a6; " + " background-color: #4a5568; " " color: white; " " border: none; " " border-radius: 4px; " @@ -148,10 +148,10 @@ class WarningDialog(QDialog): " padding: 8px 16px; " "} " "QPushButton:hover { " - " background-color: #7f8c8d; " + " background-color: #5a6578; " "} " "QPushButton:pressed { " - " background-color: #6c7b7d; " + " background-color: #3fd0ea; " "}" ) button_layout.addWidget(cancel_btn) @@ -161,7 +161,7 @@ class WarningDialog(QDialog): confirm_btn.clicked.connect(self._on_confirm) confirm_btn.setStyleSheet( "QPushButton { " - " background-color: #e67e22; " + " background-color: #8b2020; " " color: white; " " border: none; " " border-radius: 4px; " @@ -169,10 +169,10 @@ class WarningDialog(QDialog): " padding: 8px 16px; " "} " "QPushButton:hover { " - " background-color: #d35400; " + " background-color: #a02828; " "} " "QPushButton:pressed { " - " background-color: #b34700; " + " background-color: #7a1a1a; " "}" ) button_layout.addWidget(confirm_btn) @@ -200,7 +200,7 @@ class WarningDialog(QDialog): self.confirm_label.setStyleSheet( "QLabel { " " font-size: 13px; " - " color: #e67e22; " + " color: #f0c040; " " margin-bottom: 2px; " "}" ) @@ -231,7 +231,7 @@ class WarningDialog(QDialog): self.confirm_label.setStyleSheet( "QLabel { " " font-size: 13px; " - " color: #c0392b; " # Red for error + " color: #e05050; " " margin-bottom: 2px; " " font-weight: bold; " "}" @@ -242,10 +242,10 @@ class WarningDialog(QDialog): self.confirm_edit.setStyleSheet( "QLineEdit { " " font-size: 15px; " - " border: 2px solid #c0392b; " # Red border for error + " border: 2px solid #8b2020; " " border-radius: 6px; " " padding: 6px; " - " background: #3b2323; " # Darker red background - " color: #e67e22; " + " background: #3b2323; " + " color: #e0e0e0; " "}" ) \ No newline at end of file diff --git a/jackify/frontends/gui/main.py b/jackify/frontends/gui/main.py index f26a57f..0535db8 100644 --- a/jackify/frontends/gui/main.py +++ b/jackify/frontends/gui/main.py @@ -56,7 +56,13 @@ if '--env-diagnostic' in sys.argv: env_data['ld_library_path_suspicious'] = suspicious # Try to find jackify-engine from bundled context + meipass = getattr(sys, '_MEIPASS', None) + appdir = os.environ.get('APPDIR') engine_paths = [] + if appdir: + appdir_engine = Path(appdir) / 'opt' / 'jackify' / 'engine' / 'jackify-engine' + if appdir_engine.exists(): + engine_paths.append(str(appdir_engine)) if meipass: meipass_path = Path(meipass) potential_engine = meipass_path / "jackify" / "engine" / "jackify-engine" @@ -86,13 +92,24 @@ from jackify import __version__ as jackify_version logger = logging.getLogger(__name__) if '--help' in sys.argv or '-h' in sys.argv: - print("""Jackify - Native Linux Modlist Manager\n\nUsage:\n jackify [--cli] [--debug] [--version] [--help]\n\nOptions:\n --cli Launch CLI frontend\n --debug Enable debug logging\n --version Show version and exit\n --help, -h Show this help message and exit\n\nIf no options are given, the GUI will launch by default.\n""") + print("""Jackify - Native Linux Modlist Manager\n\nUsage:\n jackify [--cli] [--debug] [--version] [--help]\n jackify --list-installed [APPID]\n\nOptions:\n --cli Launch CLI frontend\n --debug Enable debug logging\n --version Show version and exit\n --help, -h Show this help message and exit\n --list-installed List installed Wine components for a modlist prefix.\n Provide APPID to target a specific prefix, or omit to\n select from configured modlists interactively.\n\nIf no options are given, the GUI will launch by default.\n""") sys.exit(0) if '-v' in sys.argv or '--version' in sys.argv or '-V' in sys.argv: print(f"Jackify version {jackify_version}") sys.exit(0) +if '--list-installed' in sys.argv: + _li_idx = sys.argv.index('--list-installed') + _li_appid = ( + sys.argv[_li_idx + 1] + if _li_idx + 1 < len(sys.argv) and not sys.argv[_li_idx + 1].startswith('-') + else None + ) + from jackify.frontends.cli.commands.list_installed import ListInstalledCommand + ListInstalledCommand().run(appid=_li_appid) + sys.exit(0) + from jackify import __version__ # Add src directory to Python path @@ -124,20 +141,6 @@ from jackify.frontends.gui.widgets.feature_placeholder import FeaturePlaceholder ENABLE_WINDOW_HEIGHT_ANIMATION = False -# Constants for styling and disclaimer -DISCLAIMER_TEXT = ( - "Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, " - "without any warranty or guarantee of stability. By using Jackify, you acknowledge that you do so at your own risk. " - "The developers are not responsible for any data loss, system issues, or other problems that may arise from its use. " - "Please back up your data and use caution." -) - -MENU_ITEMS = [ - ("Modlist Tasks", "modlist_tasks"), - ("Hoolamike Tasks", "hoolamike_tasks"), - ("Additional Tasks", "additional_tasks"), - ("Exit Jackify", "exit_jackify"), -] class JackifyMainWindow( MainWindowGeometryMixin, @@ -210,7 +213,7 @@ def resource_path(relative_path): jackify_dir = os.path.dirname(os.path.dirname(current_dir)) return os.path.join(jackify_dir, relative_path) -def main(): +def main(initial_nxm_url: str = ""): """Main entry point for the GUI application""" # CRITICAL: Enable faulthandler for segfault debugging # Print Python stack traces on segfault @@ -245,19 +248,41 @@ def main(): # Command-line --debug always takes precedence if '--debug' in sys.argv or '-d' in sys.argv: debug_mode = True - # Temporarily save CLI debug flag to config so engine can see it config_handler.set('debug_mode', True) + os.environ.setdefault('QT_LOGGING_RULES', '*.debug=true') + os.environ.setdefault('QT_DEBUG_PLUGINS', '1') import logging # Initialize root logger: jackify.log (INFO, always) + jackify-debug.log (DEBUG, debug mode only) from jackify.shared.logging import LoggingHandler root_logger = LoggingHandler().setup_application_logging(debug_mode) + _unhandled_exception_shown = False + def _unhandled_exception(exc_type, exc_value, exc_tb): + nonlocal _unhandled_exception_shown if issubclass(exc_type, KeyboardInterrupt): sys.__excepthook__(exc_type, exc_value, exc_tb) return logging.getLogger().critical("Unhandled exception", exc_info=(exc_type, exc_value, exc_tb)) + if not _unhandled_exception_shown: + _unhandled_exception_shown = True + try: + from jackify.shared.paths import get_jackify_logs_dir + log_dir = get_jackify_logs_dir() + except Exception: + log_dir = "the Jackify log directory" + + def _show_dialog(): + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical( + None, + "Unexpected Error", + f"Jackify encountered an unexpected error. The current operation may not have completed.\n\n" + f"{exc_value}\n\n" + f"Details were written to:\n{log_dir}", + ) + QTimer.singleShot(0, _show_dialog) sys.excepthook = _unhandled_exception @@ -277,7 +302,7 @@ def main(): dev_mode = '--dev' in sys.argv # Launch GUI application - app = QApplication(sys.argv) + app = QApplication.instance() or QApplication(sys.argv) # CRITICAL: Set application name before desktop file name to ensure proper window title/icon on PopOS/Ubuntu app.setApplicationName("Jackify") app.setApplicationDisplayName("Jackify") @@ -363,6 +388,11 @@ def main(): # Start background update check after window is shown window._check_for_updates_on_startup() + window._check_tool_updates_on_startup() + + if initial_nxm_url: + from PySide6.QtCore import QTimer + QTimer.singleShot(500, lambda: window._on_nxm_url_received(initial_nxm_url)) # Ensure cleanup on exit import atexit diff --git a/jackify/frontends/gui/mixins/main_window_backend.py b/jackify/frontends/gui/mixins/main_window_backend.py index f96709d..e4ea018 100644 --- a/jackify/frontends/gui/mixins/main_window_backend.py +++ b/jackify/frontends/gui/mixins/main_window_backend.py @@ -32,6 +32,15 @@ class MainWindowBackendMixin: from jackify.backend.services.update_service import UpdateService from jackify import __version__ self.update_service = UpdateService(__version__) + + from jackify.backend.services.nxm_ipc import NxmIpcServer + self._nxm_ipc_server = NxmIpcServer(self) + self._nxm_ipc_server.url_received.connect(self._on_nxm_url_received) + self._nxm_ipc_server.start() + + from jackify.backend.services.nxm_protocol import ensure_nxm_registered + ensure_nxm_registered() + logger.debug(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}") def _is_steamdeck(self): diff --git a/jackify/frontends/gui/mixins/main_window_dialogs.py b/jackify/frontends/gui/mixins/main_window_dialogs.py index 3f85cfa..b12c3a4 100644 --- a/jackify/frontends/gui/mixins/main_window_dialogs.py +++ b/jackify/frontends/gui/mixins/main_window_dialogs.py @@ -126,8 +126,39 @@ class MainWindowDialogsMixin: start_new_session=True ) + def _on_nxm_url_received(self, url: str) -> None: + """Handle an incoming nxm:// URL from the IPC server or initial launch.""" + try: + from jackify.backend.services.nxm_url import parse_nxm_url + from jackify.backend.services import nxm_session + from jackify.tools.verify_install import discover_installed_modlists + from jackify.frontends.gui.dialogs.nxm_download_dialog import NxmDownloadDialog + + nxm = parse_nxm_url(url) + modlists = discover_installed_modlists() + if not modlists: + from jackify.frontends.gui.dialogs.nxm_download_dialog import show_no_modlists_error + show_no_modlists_error() + return + + # Prefer the actively running MO2 instance; fall back to session memory. + auto_start = nxm_session.detect_active_mo2_modlist(modlists) + if auto_start is None: + remembered = nxm_session.get_remembered_modlist() + if remembered: + auto_start = next((m for m in modlists if m["name"] == remembered), None) + + dlg = NxmDownloadDialog(nxm, modlists, parent=self, auto_start_modlist=auto_start) + dlg.show() + dlg.raise_() + dlg.activateWindow() + except Exception as e: + logger.warning("Failed to handle NXM URL %r: %s", url, e) + def cleanup_processes(self): try: + if hasattr(self, '_nxm_ipc_server') and self._nxm_ipc_server is not None: + self._nxm_ipc_server.stop() if hasattr(self, '_update_thread') and self._update_thread is not None: self._update_thread = self._stop_qthread(self._update_thread, "_update_thread") if hasattr(self, '_gallery_cache_preload_thread') and self._gallery_cache_preload_thread is not None: diff --git a/jackify/frontends/gui/mixins/main_window_geometry.py b/jackify/frontends/gui/mixins/main_window_geometry.py index 5b49d90..c106ef2 100644 --- a/jackify/frontends/gui/mixins/main_window_geometry.py +++ b/jackify/frontends/gui/mixins/main_window_geometry.py @@ -135,10 +135,10 @@ class MainWindowGeometryMixin: self.showMaximized() def _on_child_resize_request(self, mode: str): - logger.debug(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}") + logger.debug(f"_on_child_resize_request called with mode='{mode}', current_size={self.size()}") try: if self.system_info and self.system_info.is_steamdeck: - logger.debug("DEBUG: Steam Deck detected, ignoring resize request") + logger.debug("Steam Deck detected, ignoring resize request") try: if hasattr(self, 'install_ttw_screen') and self.install_ttw_screen.show_details_checkbox: self.install_ttw_screen.show_details_checkbox.setVisible(False) @@ -183,7 +183,7 @@ class MainWindowGeometryMixin: before = self.size() self._programmatic_resize = True self.resize(self.size().width(), target_height) - logger.debug(f"DEBUG: Animated fallback resize from {before} to {self.size()}") + logger.debug(f"Animated fallback resize from {before} to {self.size()}") QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False)) return start_rect = self.geometry() diff --git a/jackify/frontends/gui/mixins/main_window_startup.py b/jackify/frontends/gui/mixins/main_window_startup.py index f822d6a..505a3e7 100644 --- a/jackify/frontends/gui/mixins/main_window_startup.py +++ b/jackify/frontends/gui/mixins/main_window_startup.py @@ -65,6 +65,47 @@ class MainWindowStartupMixin: except Exception as e: print(f"Error checking protontricks: {e}") + def _check_tool_updates_on_startup(self): + class _ToolUpdateCheckThread(QThread): + updates_found = Signal(bool) + + def run(self): + try: + from jackify.backend.services.tool_registry import ToolRegistry, get_effective_definitions + registry = ToolRegistry() + for defn in get_effective_definitions(): + if defn.pinned_version is not None: + continue + status = registry.get_status(defn.tool_id) + if not status or not status.installed: + continue + logger.debug( + "Startup tool update check: %s installed=%s version=%s", + defn.tool_id, status.installed, status.installed_version, + ) + tag = registry.check_latest_version(defn.tool_id) + logger.debug("Startup tool update check: %s latest=%s", defn.tool_id, tag) + if tag and status.installed_version and tag.lstrip("v") != status.installed_version.lstrip("v"): + logger.debug("Startup tool update check: update available for %s", defn.tool_id) + self.updates_found.emit(True) + return + self.updates_found.emit(False) + except Exception as e: + logger.warning("Tool update check failed: %s", e, exc_info=True) + self.updates_found.emit(False) + + def on_result(has_updates: bool): + from PySide6.QtCore import QTimer + QTimer.singleShot(0, lambda: ( + self.main_menu.notify_tool_updates(has_updates) + if hasattr(self, 'main_menu') and hasattr(self.main_menu, 'notify_tool_updates') + else None + )) + + self._tool_update_check_thread = _ToolUpdateCheckThread() + self._tool_update_check_thread.updates_found.connect(on_result) + self._tool_update_check_thread.start() + def _check_for_updates_on_startup(self): try: logger.debug("Checking for updates on startup...") diff --git a/jackify/frontends/gui/mixins/main_window_ui.py b/jackify/frontends/gui/mixins/main_window_ui.py index fc48e40..04c6384 100644 --- a/jackify/frontends/gui/mixins/main_window_ui.py +++ b/jackify/frontends/gui/mixins/main_window_ui.py @@ -76,13 +76,13 @@ class MainWindowUIMixin: version_label.setStyleSheet("color: #bbb; font-size: 13px;") bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft) bottom_bar_layout.addStretch(1) - kofi_link = QLabel('Support on Ko-fi') - kofi_link.setStyleSheet("color: #72A5F2; font-size: 13px;") + kofi_link = QLabel('Support on Ko-fi') + kofi_link.setStyleSheet("color: #3fd0ea; font-size: 13px;") kofi_link.setTextInteractionFlags(Qt.TextBrowserInteraction) kofi_link.setOpenExternalLinks(False) kofi_link.linkActivated.connect(lambda: self._open_url("https://ko-fi.com/omni1")) kofi_link.setToolTip("Support Jackify development") - bottom_bar_layout.addWidget(kofi_link, alignment=Qt.AlignCenter) + bottom_bar_layout.addWidget(kofi_link) bottom_bar_layout.addStretch(1) settings_btn = QLabel('Settings') settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;") @@ -229,9 +229,9 @@ class MainWindowUIMixin: return screen def _make_third_party_tools_screen(self): - from jackify.frontends.gui.screens.third_party_tools import ThirdPartyToolsScreen - screen = ThirdPartyToolsScreen( - stacked_widget=self.stacked_widget, main_menu_index=0, + from jackify.frontends.gui.screens.tools_hub import ToolsHubScreen + screen = ToolsHubScreen( + stacked_widget=self.stacked_widget, main_menu_index=0, ttw_screen_index=5, ) self.third_party_tools_screen = screen return screen diff --git a/jackify/frontends/gui/mixins/thread_lifecycle_mixin.py b/jackify/frontends/gui/mixins/thread_lifecycle_mixin.py index 6cca3ba..c832076 100644 --- a/jackify/frontends/gui/mixins/thread_lifecycle_mixin.py +++ b/jackify/frontends/gui/mixins/thread_lifecycle_mixin.py @@ -19,6 +19,7 @@ Usage: """ import logging +import warnings from typing import List, Optional logger = logging.getLogger(__name__) @@ -46,10 +47,12 @@ class ThreadLifecycleMixin: return None for name in (signal_names or []): - try: - getattr(thread, name).disconnect() - except Exception: - pass + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + try: + getattr(thread, name).disconnect() + except Exception: + pass # Register in the module-level set so the reference survives screen destruction. # Remove from registry when the thread finishes so it can be GC'd cleanly. @@ -68,6 +71,36 @@ class ThreadLifecycleMixin: pass self._park_all_threads() + def _kill_prefix_wine_processes(self, appid: str = '') -> None: + """Kill wine/winetricks subprocesses on user-initiated cancel. + + Called before parking threads so the blocked subprocess.run() calls inside + the thread actually return rather than running until completion. + """ + import os + import subprocess + for pattern in ('winetricks', 'protontricks'): + subprocess.run(['pkill', '-9', '-f', pattern], capture_output=True) + if not appid: + return + try: + from jackify.backend.handlers.path_handler import PathHandler + from jackify.backend.handlers.winetricks_handler import WinetricksHandler + compat = PathHandler.find_compat_data(str(appid)) + if not compat: + return + pfx = str(compat / 'pfx') + wine_bin = WinetricksHandler()._get_wine_binary_for_prefix(pfx) + if not wine_bin: + return + wineserver = os.path.join(os.path.dirname(wine_bin), 'wineserver') + if os.path.isfile(wineserver): + subprocess.run([wineserver, '-k'], + env={**os.environ, 'WINEPREFIX': pfx}, + timeout=5, capture_output=True) + except Exception: + pass + def _park_all_threads(self): """Park every running QThread attribute found on this instance. diff --git a/jackify/frontends/gui/screens/additional_tasks.py b/jackify/frontends/gui/screens/additional_tasks.py index aba4dbb..c9fa954 100644 --- a/jackify/frontends/gui/screens/additional_tasks.py +++ b/jackify/frontends/gui/screens/additional_tasks.py @@ -1,8 +1,7 @@ """ Additional Tasks & Tools Screen -Simple screen for TTW automation only. -Follows the same pattern as ModlistTasksScreen. +Additional tools and automation. Follows the same pattern as ModlistTasksScreen. """ import logging @@ -23,7 +22,7 @@ logger = logging.getLogger(__name__) class AdditionalTasksScreen(QWidget): - """Simple Additional Tasks screen for TTW only""" + """Additional Tasks screen for automation and standalone tools.""" def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None, install_mo2_screen_index: int = 9): @@ -67,7 +66,7 @@ class AdditionalTasksScreen(QWidget): header_layout.addSpacing(10) # Description area with fixed height - desc = QLabel("TTW automation, Wabbajack installer, and additional tools.") + desc = QLabel("Wabbajack installer, MO2 setup, and additional tools.") desc.setWordWrap(True) desc.setStyleSheet("color: #ccc; font-size: 13px;") desc.setAlignment(Qt.AlignHCenter) @@ -93,10 +92,11 @@ class AdditionalTasksScreen(QWidget): """Set up the menu buttons section""" # Menu options MENU_ITEMS = [ - ("Install TTW", "ttw_install", "Install Tale of Two Wastelands using TTW_Linux_Installer"), - ("Install Wabbajack", "wabbajack_install", "Install Wabbajack.exe via Proton (automated setup)"), - ("Setup Mod Organizer 2", "setup_mo2", "Download and configure a standalone MO2 instance"), + ("Run Install Verifier", "run_verifier", "Check an installed modlist for common configuration problems"), ("Configure Tool Compatibility", "tool_config", "Apply xEdit, Pandora and DLL fixes to an existing modlist prefix"), + ("Setup Mod Organizer 2", "setup_mo2", "Download and configure a standalone MO2 instance"), + ("Install Wabbajack", "wabbajack_install", "Install Wabbajack.exe via Proton (automated setup)"), + ("Create Diagnostic Bundle", "diagnostic_bundle", "Package logs and system info for support reporting"), ("Return to Main Menu", "return_main_menu", "Go back to the main menu"), ] @@ -148,25 +148,19 @@ class AdditionalTasksScreen(QWidget): def _handle_button_click(self, action_id): """Handle button clicks""" - if action_id == "ttw_install": - self._show_ttw_info() + if action_id == "run_verifier": + self._run_install_verifier() elif action_id == "wabbajack_install": self._show_wabbajack_installer() elif action_id == "setup_mo2": self._show_mo2_setup() elif action_id == "tool_config": self._show_tool_config() - elif action_id == "coming_soon": - self._show_coming_soon_info() + elif action_id == "diagnostic_bundle": + self._run_diagnostic_bundle() elif action_id == "return_main_menu": self._return_to_main_menu() - def _show_ttw_info(self): - """Navigate to TTW installation screen""" - if self.stacked_widget: - # Navigate to TTW installation screen (index 5) - self.stacked_widget.setCurrentIndex(5) - def _show_wabbajack_installer(self): """Navigate to Wabbajack installer screen""" if self.stacked_widget: @@ -178,20 +172,202 @@ class AdditionalTasksScreen(QWidget): if self.stacked_widget: self.stacked_widget.setCurrentIndex(self.install_mo2_screen_index) - def _show_coming_soon_info(self): - """Show coming soon info""" - from ..services.message_service import MessageService - MessageService.information( - self, - "Coming Soon", - "Additional tools and features will be added in future updates.\n\n" - "Check back later for more functionality!" - ) - def _show_tool_config(self): if self.stacked_widget: self.stacked_widget.setCurrentIndex(11) + def _run_install_verifier(self): + """Prompt user to pick a modlist, run the verifier, and show results.""" + from ..services.message_service import MessageService + try: + from jackify.backend.services.install_verifier_service import _load_verifier + verifier_mod = _load_verifier() + modlists = verifier_mod.discover_installed_modlists() + except Exception as e: + MessageService.critical( + self, + "Verifier Error", + f"Could not load install verifier: {e}", + ) + return + + if not modlists: + MessageService.information( + self, + "No Modlists Found", + "No installed modlists were found in Steam shortcuts.\n\n" + "Ensure ModOrganizer.exe shortcuts exist in Steam for your modlists.", + ) + return + + from PySide6.QtWidgets import QDialog, QVBoxLayout, QListWidget, QListWidgetItem, QPushButton, QLabel, QHBoxLayout + picker = QDialog(self) + picker.setWindowTitle("Select Modlist to Verify") + picker.setMinimumWidth(480) + picker.setMinimumHeight(260) + picker_layout = QVBoxLayout(picker) + picker_layout.addWidget(QLabel("Select a modlist to verify:")) + + lw = QListWidget() + for m in modlists: + pfx_ok = m["pfx"] and m["pfx"].is_dir() + suffix = "" if pfx_ok else " (prefix not found)" + item = QListWidgetItem(f"{m['name']}{suffix}") + item.setData(1000, m) + lw.addItem(item) + lw.setCurrentRow(0) + picker_layout.addWidget(lw) + + btn_row = QHBoxLayout() + ok_btn = QPushButton("Run Verifier") + cancel_btn = QPushButton("Cancel") + btn_row.addStretch() + btn_row.addWidget(ok_btn) + btn_row.addWidget(cancel_btn) + picker_layout.addLayout(btn_row) + + ok_btn.clicked.connect(picker.accept) + cancel_btn.clicked.connect(picker.reject) + lw.itemDoubleClicked.connect(lambda _: picker.accept()) + + if picker.exec() != QDialog.Accepted: + return + + selected_item = lw.currentItem() + if not selected_item: + return + selected = selected_item.data(1000) + + pfx = selected.get("pfx") + if not pfx or not pfx.is_dir(): + MessageService.warning( + self, + "Prefix Not Found", + f"The Proton prefix for '{selected['name']}' was not found.\n\n" + "Launch the modlist from Steam at least once to create the prefix.", + ) + return + + from PySide6.QtCore import QThread, Signal as _Signal + + class _VerifierThread(QThread): + done = _Signal(object) + + def __init__(self, verifier_module, entry, parent=None): + super().__init__(parent) + self._verifier = verifier_module + self._entry = entry + + def run(self): + try: + r = self._verifier.run_verification( + pfx=self._entry["pfx"], + modlist_dir=self._entry["modlist_dir"], + game_type=self._entry["game_type"], + appid=self._entry["appid"], + modlist_name=self._entry.get("name", ""), + ) + except Exception as exc: + logger.warning("On-demand verifier error: %s", exc) + r = None + self.done.emit(r) + + from jackify.frontends.gui.services.message_service import MessageService as _MS + progress_dlg = QDialog(self) + progress_dlg.setWindowTitle("Verifying...") + progress_dlg.setModal(True) + prog_layout = QVBoxLayout(progress_dlg) + prog_layout.addWidget(QLabel(f"Running verifier for '{selected['name']}'...\nThis may take a moment.")) + progress_dlg.setFixedSize(340, 100) + progress_dlg.show() + + self._verifier_ondemand_thread = _VerifierThread(verifier_mod, selected, parent=self) + + def _on_done(results): + progress_dlg.accept() + self._verifier_ondemand_thread = None + if results is None: + MessageService.critical( + self, + "Verifier Error", + "The verifier encountered an error and could not complete.", + ) + return + from jackify.frontends.gui.dialogs.verification_results_dialog import VerificationResultsDialog + dlg = VerificationResultsDialog(results, parent=self) + dlg.exec() + + self._verifier_ondemand_thread.done.connect(_on_done) + self._verifier_ondemand_thread.start() + + def _run_diagnostic_bundle(self): + """Open the diagnostic bundle dialog; bundle is only created when the user confirms.""" + from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit + ) + from PySide6.QtCore import QThread, Signal as _Signal + + class _BundleThread(QThread): + done = _Signal(object, str) # (bundle_path or None, error_msg) + + def run(self): + try: + from jackify.backend.services.diagnostic_service import build_bundle + path = build_bundle() + self.done.emit(path, "") + except Exception as exc: + self.done.emit(None, str(exc)) + + dlg = QDialog(self) + dlg.setWindowTitle("Diagnostic Bundle") + dlg.setMinimumWidth(600) + dlg.setMinimumHeight(220) + dlg.setStyleSheet("QDialog { background: #181818; color: #fff; }") + + layout = QVBoxLayout(dlg) + layout.setContentsMargins(20, 16, 20, 16) + layout.setSpacing(10) + + status_label = QLabel("Package logs and system info into a file for support reporting.") + layout.addWidget(status_label) + + path_box = QTextEdit() + path_box.setReadOnly(True) + path_box.setMinimumHeight(60) + path_box.setVisible(False) + layout.addWidget(path_box) + + btn_row = QHBoxLayout() + create_btn = QPushButton("Create Bundle") + cancel_btn = QPushButton("Cancel") + btn_row.addWidget(create_btn) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + cancel_btn.clicked.connect(dlg.reject) + + def _on_create(): + create_btn.setEnabled(False) + cancel_btn.setEnabled(False) + status_label.setText("Collecting logs and system info...") + self._diag_thread = _BundleThread(parent=self) + self._diag_thread.done.connect(_on_done) + self._diag_thread.start() + + def _on_done(bundle_path, error): + self._diag_thread = None + cancel_btn.setEnabled(True) + cancel_btn.setText("Close") + if not bundle_path: + status_label.setText(f"Failed: {error}") + return + status_label.setText("Bundle created:") + path_box.setPlainText(str(bundle_path)) + path_box.setVisible(True) + + create_btn.clicked.connect(_on_create) + dlg.exec() + def _return_to_main_menu(self): """Return to main menu""" if self.stacked_widget: diff --git a/jackify/frontends/gui/screens/configure_existing_modlist.py b/jackify/frontends/gui/screens/configure_existing_modlist.py index e0534b9..1a14dec 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist.py @@ -22,7 +22,6 @@ from jackify.backend.handlers.subprocess_utils import ProcessManager from jackify.backend.services.api_key_service import APIKeyService from jackify.backend.services.resolution_service import ResolutionService from jackify.backend.handlers.config_handler import ConfigHandler -from ..dialogs import SuccessDialog from jackify.frontends.gui.services.message_service import MessageService import logging logger = logging.getLogger(__name__) @@ -33,12 +32,14 @@ from .configure_existing_modlist_console import ConfigureExistingModlistConsoleM from .screen_back_mixin import ScreenBackMixin from .install_modlist_ttw import TTWIntegrationMixin from .install_modlist_postinstall import PostInstallFeedbackMixin +from .install_verifier_mixin import InstallVerifierMixin from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin class ConfigureExistingModlistScreen( ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, + InstallVerifierMixin, ConfigureExistingModlistUIMixin, ConfigureExistingModlistWorkflowMixin, ConfigureExistingModlistShortcutsMixin, @@ -73,6 +74,7 @@ class ConfigureExistingModlistScreen( if getattr(self, '_vnv_controller', None) is not None: self._vnv_controller.cleanup() self._vnv_controller = None + self._kill_prefix_wine_processes(str(getattr(self, '_current_appid', '') or '')) self.cleanup_processes() self.collapse_show_details_before_leave() self.go_back() @@ -105,6 +107,10 @@ class ConfigureExistingModlistScreen( def on_configuration_complete(self, success, message, modlist_name, enb_detected=False): """Handle configuration completion""" + if getattr(self, '_awaiting_steam_restart', False): + self._deferred_completion_args = (success, message, modlist_name, enb_detected) + return + # Re-enable all controls when workflow completes self._enable_controls_after_operation() @@ -137,34 +143,27 @@ class ConfigureExistingModlistScreen( 'time_taken': self._calculate_time_taken(), 'game_name': getattr(self, '_current_game_name', None), 'enb_detected': enb_detected, + 'install_dir': install_dir, + 'game_type': game_type, + 'appid': getattr(self, '_current_appid', '') or '', } return # Calculate time taken time_taken = self._calculate_time_taken() - # Clear Activity window before showing success dialog - self.file_progress_list.clear() - - # Show success dialog with celebration - success_dialog = SuccessDialog( - modlist_name=modlist_name, - workflow_type="configure_existing", - time_taken=time_taken, - game_name=getattr(self, '_current_game_name', None), - parent=self + self._run_verifier_then_show_success( + install_dir=install_dir, + game_type=game_type, + appid=getattr(self, '_current_appid', '') or '', + success_params={ + 'modlist_name': modlist_name, + 'workflow_type': 'configure_existing', + 'time_taken': time_taken, + 'game_name': getattr(self, '_current_game_name', None), + 'enb_detected': enb_detected, + }, ) - success_dialog.show() - - # Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection) - if enb_detected: - try: - from ..dialogs.enb_proton_dialog import ENBProtonDialog - enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self) - enb_dialog.exec() - except Exception as e: - import logging - logging.getLogger(__name__).warning("Failed to show ENB dialog: %s", e) else: self._safe_append_text(f"Configuration failed: {message}") MessageService.show_error(self, configuration_failed(str(message))) @@ -184,17 +183,19 @@ class ConfigureExistingModlistScreen( if not hasattr(self, 'config_thread') or self.config_thread is None: return - try: - self.config_thread.progress_update.disconnect() - self.config_thread.configuration_complete.disconnect() - self.config_thread.error_occurred.disconnect() - except (RuntimeError, TypeError): - pass + for sig_name in ('progress_update', 'configuration_complete', 'error_occurred', 'steam_restart_needed'): + try: + getattr(self.config_thread, sig_name).disconnect() + except (RuntimeError, TypeError, AttributeError): + pass if self.config_thread.isRunning(): self.config_thread.quit() - self.config_thread.wait(5000) + # wait() ensures the OS thread has fully exited before deleteLater, + # preventing "QThread destroyed while still running" if isRunning() + # briefly trails the actual thread exit. + self.config_thread.wait(3000) self.config_thread.deleteLater() self.config_thread = None @@ -225,7 +226,7 @@ class ConfigureExistingModlistScreen( def cleanup(self): """Clean up any running threads when the screen is closed""" - logger.debug("DEBUG: cleanup called - cleaning up ConfigurationThread") + logger.debug("cleanup called - cleaning up ConfigurationThread") if getattr(self, '_vnv_controller', None) is not None: self._vnv_controller.cleanup() @@ -233,7 +234,7 @@ class ConfigureExistingModlistScreen( # Clean up config thread if running if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning(): - logger.debug("DEBUG: Parking ConfigurationThread") + logger.debug("Parking ConfigurationThread") self.config_thread = self._park_thread( self.config_thread, ["progress_update", "configuration_complete", "error_occurred"], diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_console.py b/jackify/frontends/gui/screens/configure_existing_modlist_console.py index 6c262b3..9173bf1 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist_console.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist_console.py @@ -12,6 +12,52 @@ class ConfigureExistingModlistConsoleMixin: def _handle_progress_update(self, text): """Handle progress updates - update console, activity window, and progress indicator""" + if text.startswith("[NATIVE_DL] "): + parts = text.split(None, 3) + if len(parts) == 4: + _, component, pct_str, speed_str = parts + try: + pct, speed = float(pct_str), float(speed_str) + self.file_progress_list.update_or_add_item( + "__native_component__", + f"Wine component: {component} | {pct:.0f}% ({speed:.1f} MB/s)", + pct, + ) + except ValueError: + pass + return + if text.startswith("[NATIVE_INSTALL] "): + component = text[17:].strip() + self._stop_component_install_pulse() + done = getattr(self, '_native_done_components', 0) + total = getattr(self, '_native_total_components', 0) + remaining = max(0, total - done) + suffix = f" ({remaining} remaining)" if remaining > 0 else "" + self.file_progress_list.update_or_add_item( + "__native_component__", + f"Wine component: {component}{suffix}", + 0.0, + ) + self.progress_indicator.set_status(f"Installing {component}...", 60) + self._current_native_component = component + self._native_done_components = done + 1 + return + if text.startswith("[NATIVE_WAIT] "): + parts = text.split(None, 2) + if len(parts) >= 3: + component, elapsed_s = parts[1], parts[2].strip() + done = getattr(self, '_native_done_components', 1) + total = getattr(self, '_native_total_components', 0) + remaining = max(0, total - done) + suffix = f" ({remaining} remaining)" if remaining > 0 else "" + self.file_progress_list.update_or_add_item( + "__native_component__", + f"Wine component: {component} | {elapsed_s}s{suffix}", + 0.0, + ) + self.progress_indicator.set_status(f"Installing {component}... ({elapsed_s}s)", 60) + return + # Always append to console self._safe_append_text(text) @@ -27,13 +73,24 @@ class ConfigureExistingModlistConsoleMixin: self._stop_component_install_pulse() self.progress_indicator.set_status("Applying registry files...", 40) self.file_progress_list.update_or_add_item("__phase__", "Applying registry...", 0.0) - elif "installing wine components" in message_lower or "wine component" in message_lower: + elif ( + "installing wine components" in message_lower + or "wine component" in message_lower + or "vcrun" in message_lower + or ("dotnet" in message_lower and "fix" not in message_lower) + ): self.progress_indicator.set_status("Installing wine components...", 60) - if not hasattr(self, '_component_install_timer') or not self._component_install_timer: - self._start_component_install_pulse() comp_list = self._parse_wine_components_message(text) if comp_list: - self._start_component_install_pulse_with_components(comp_list) + self._native_total_components = len(comp_list) + self._native_done_components = 0 + self.file_progress_list.update_or_add_item( + "__native_component__", + f"Wine components: {len(comp_list)} queued", + 0.0, + ) + elif not getattr(self, '_component_install_timer', None) or not self._component_install_timer.isActive(): + self._start_component_install_pulse() elif "wine components verified" in message_lower or "wine components installed" in message_lower: self._stop_component_install_pulse() self.progress_indicator.set_status("Wine components installed", 65) @@ -127,14 +184,22 @@ class ConfigureExistingModlistConsoleMixin: if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time: return if hasattr(self, '_component_install_list') and self._component_install_list: - progresses = [ - FileProgress( - filename=f"Wine component: {comp}", - operation=OperationType.UNKNOWN, - percent=0.0, - ) - for comp in self._component_install_list - ] + dl_state = getattr(self, '_native_component_progress', {}) + progresses = [] + for comp in self._component_install_list: + if comp in dl_state: + pct, speed = dl_state[comp] + progresses.append(FileProgress( + filename=f"Wine component: {comp} | {pct:.0f}% ({speed:.1f} MB/s)", + operation=OperationType.DOWNLOAD, + percent=pct, + )) + else: + progresses.append(FileProgress( + filename=f"Wine component: {comp}", + operation=OperationType.UNKNOWN, + percent=0.0, + )) self.file_progress_list.update_files(progresses, current_phase=None) else: self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0) @@ -146,5 +211,7 @@ class ConfigureExistingModlistConsoleMixin: self._component_install_timer = None if hasattr(self, '_component_install_list'): del self._component_install_list + if hasattr(self, '_native_component_progress'): + del self._native_component_progress diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_ui.py b/jackify/frontends/gui/screens/configure_existing_modlist_ui.py index ac1c22f..96fbb03 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist_ui.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist_ui.py @@ -19,7 +19,7 @@ class ConfigureExistingModlistUIMixin: def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None): super().__init__() - logger.debug("DEBUG: ConfigureExistingModlistScreen __init__ called") + logger.debug("ConfigureExistingModlistScreen __init__ called") self.stacked_widget = stacked_widget self.main_menu_index = main_menu_index from jackify.backend.models.configuration import SystemInfo @@ -183,7 +183,7 @@ class ConfigureExistingModlistUIMixin: combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) self.resolution_combo.setCurrentIndex(resolution_index) - logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") + logger.debug(f"Loaded saved resolution: {saved_resolution} (index: {resolution_index})") elif is_steam_deck: # Set default to 1280x800 (Steam Deck) combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py b/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py index 4d4b480..e181d7f 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py @@ -56,20 +56,21 @@ class ConfigureExistingModlistWorkflowMixin: MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium") self._enable_controls_after_operation() return - self._current_appid = shortcut.get('AppID', shortcut.get('appid', '')) + raw_appid = shortcut.get('AppID', shortcut.get('appid', '')) + self._current_appid = str(raw_appid) if raw_appid != '' else '' resolution = self.resolution_combo.currentText() # Handle resolution saving if resolution and resolution != "Leave unchanged": success = self.resolution_service.save_resolution(resolution) if success: - logger.debug(f"DEBUG: Resolution saved successfully: {resolution}") + logger.debug(f"Resolution saved successfully: {resolution}") else: - logger.debug("DEBUG: Failed to save resolution") + logger.debug("Failed to save resolution") else: # Clear saved resolution if "Leave unchanged" is selected if self.resolution_service.has_saved_resolution(): self.resolution_service.clear_saved_resolution() - logger.debug("DEBUG: Saved resolution cleared") + logger.debug("Saved resolution cleared") # Start the workflow (no shortcut creation needed) self.start_workflow(modlist_name, install_dir, resolution) @@ -104,6 +105,7 @@ class ConfigureExistingModlistWorkflowMixin: progress_update = Signal(str) configuration_complete = Signal(bool, str, str, bool) error_occurred = Signal(str) + steam_restart_needed = Signal(str, str, str) # app_name, exe_path, dl_path def __init__(self, modlist_name, install_dir, resolution, system_info, detect_func): super().__init__() @@ -128,10 +130,11 @@ class ConfigureExistingModlistWorkflowMixin: # Create modlist context for existing modlist configuration mo2_exe_path = os.path.join(self.install_dir, "ModOrganizer.exe") + from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir modlist_context = ModlistContext( name=self.modlist_name, install_dir=Path(self.install_dir), - download_dir=None, + download_dir=resolve_mo2_download_dir(Path(self.install_dir)), game_type=detected_game_type, nexus_api_key='', # Not needed for configuration-only modlist_value='', # Not needed for existing modlist @@ -147,26 +150,31 @@ class ConfigureExistingModlistWorkflowMixin: # Define callbacks def progress_callback(message): self.progress_update.emit(message) - + + # Store completion args rather than emitting immediately so we can + # emit steam_restart_needed first when a restart is required. + completion_args = [None] + def completion_callback(success, message, modlist_name, enb_detected=False): - self.configuration_complete.emit(success, message, modlist_name, enb_detected) - - def manual_steps_callback(modlist_name, retry_count): - # Existing modlists shouldn't need manual steps, but handle gracefully - self.progress_update.emit(f"Note: Manual steps callback triggered for {modlist_name} (retry {retry_count})") - - # Call the working configuration service method + completion_args[0] = (success, message, modlist_name, enb_detected) + self.progress_update.emit("Starting existing modlist configuration...") - - # For existing modlists, call configure_modlist_post_steam directly - # since Steam setup and manual steps should already be done success = modlist_service.configure_modlist_post_steam( context=modlist_context, progress_callback=progress_callback, - manual_steps_callback=manual_steps_callback, completion_callback=completion_callback ) + if getattr(modlist_context, 'steam_restart_needed', False): + self.steam_restart_needed.emit( + getattr(modlist_context, 'mounts_app_name', ''), + getattr(modlist_context, 'mounts_exe_path', ''), + getattr(modlist_context, 'mounts_dl_path', ''), + ) + + if completion_args[0] is not None: + self.configuration_complete.emit(*completion_args[0]) + if not success: self.error_occurred.emit( "Configuration did not complete successfully. " @@ -183,12 +191,53 @@ class ConfigureExistingModlistWorkflowMixin: self.config_thread.progress_update.connect(self._handle_progress_update) self.config_thread.configuration_complete.connect(self.on_configuration_complete) self.config_thread.error_occurred.connect(self.on_configuration_error) + self.config_thread.steam_restart_needed.connect(self._on_steam_restart_needed) # (app_name, exe_path, dl_path) self.config_thread.start() except Exception as e: self._safe_append_text(f"[ERROR] Failed to start configuration: {e}") MessageService.show_error(self, configuration_failed(str(e))) + def _on_steam_restart_needed(self, app_name: str, exe_path: str, dl_path: str): + """Prompt before stopping Steam to write a deferred STEAM_COMPAT_MOUNTS update. + + Receives the shortcut identity and path from the signal so it can call + ensure_mounts_in_steam_compat on the GUI's ShortcutHandler (not the backend's). + + Sets _awaiting_steam_restart before showing the dialog so that + on_configuration_complete (fired by the nested event loop during exec()) + defers the success/ENB dialogs until after this handler finishes. + """ + from PySide6.QtWidgets import QMessageBox + from jackify.frontends.gui.services.message_service import MessageService + self._awaiting_steam_restart = True + try: + reply = MessageService.question( + self, + "Restart Steam?", + "The download directory mount needs to be added to STEAM_COMPAT_MOUNTS for this " + "modlist. Steam must be stopped to write this change safely.\n\n" + "Any running game will be closed. Do you want Jackify to restart Steam now?", + safety_level="medium", + ) + if reply == QMessageBox.No: + logger.info("User declined Steam restart; STEAM_COMPAT_MOUNTS update skipped") + else: + try: + from jackify.backend.services.steam_restart_service import shutdown_steam, start_steam + shutdown_steam() + self.shortcut_handler.ensure_mounts_in_steam_compat(app_name, exe_path, dl_path) + start_steam() + except Exception as e: + logger.warning("Steam restart/mounts update failed: %s", e) + finally: + self._awaiting_steam_restart = False + if hasattr(self, '_deferred_completion_args') and self._deferred_completion_args is not None: + args = self._deferred_completion_args + self._deferred_completion_args = None + from PySide6.QtCore import QTimer + QTimer.singleShot(0, lambda: self.on_configuration_complete(*args)) + def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool: """Check if VNV automation should run and start it if applicable. @@ -227,26 +276,18 @@ class ConfigureExistingModlistWorkflowMixin: if hasattr(self, '_pending_success_dialog_params'): params = self._pending_success_dialog_params del self._pending_success_dialog_params - - self.file_progress_list.clear() - - from ..dialogs import SuccessDialog - success_dialog = SuccessDialog( - modlist_name=params['modlist_name'], - workflow_type=params['workflow_type'], - time_taken=params['time_taken'], - game_name=params['game_name'], - parent=self, + self._run_verifier_then_show_success( + install_dir=params.get('install_dir', ''), + game_type=params.get('game_type', 'unknown'), + appid=params.get('appid', ''), + success_params={ + 'modlist_name': params['modlist_name'], + 'workflow_type': params['workflow_type'], + 'time_taken': params['time_taken'], + 'game_name': params.get('game_name'), + 'enb_detected': params.get('enb_detected', False), + }, ) - success_dialog.show() - - if params.get('enb_detected'): - try: - from ..dialogs.enb_proton_dialog import ENBProtonDialog - enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self) - enb_dialog.exec() - except Exception as e: - logger.warning("Failed to show ENB dialog: %s", e) def show_manual_steps_dialog(self, extra_warning=""): modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist" diff --git a/jackify/frontends/gui/screens/configure_new_modlist.py b/jackify/frontends/gui/screens/configure_new_modlist.py index c4b93f1..7f40297 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist.py +++ b/jackify/frontends/gui/screens/configure_new_modlist.py @@ -24,7 +24,6 @@ from jackify.backend.handlers.subprocess_utils import ProcessManager from jackify.backend.services.api_key_service import APIKeyService from jackify.backend.services.resolution_service import ResolutionService from jackify.backend.handlers.config_handler import ConfigHandler -from ..dialogs import SuccessDialog from PySide6.QtWidgets import QApplication from jackify.frontends.gui.services.message_service import MessageService from jackify.shared.resolution_utils import get_resolution_fallback @@ -36,11 +35,12 @@ from .configure_new_modlist_dialogs import ConfigureNewModlistDialogsMixin, Modl from .screen_back_mixin import ScreenBackMixin from .install_modlist_ttw import TTWIntegrationMixin from .install_modlist_postinstall import PostInstallFeedbackMixin +from .install_verifier_mixin import InstallVerifierMixin from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin logger = logging.getLogger(__name__) -class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget): +class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, InstallVerifierMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget): resize_request = Signal(str) def cancel_and_cleanup(self): @@ -48,6 +48,8 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr if getattr(self, '_vnv_controller', None) is not None: self._vnv_controller.cleanup() self._vnv_controller = None + appid = str(getattr(self, 'context', {}).get('appid', '') or '') + self._kill_prefix_wine_processes(appid) self.cleanup_processes() self.collapse_show_details_before_leave() self.go_back() @@ -84,31 +86,27 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr 'time_taken': self._calculate_time_taken(), 'game_name': getattr(self, '_current_game_name', None), 'enb_detected': enb_detected, + 'install_dir': install_dir, + 'game_type': game_type, + 'appid': getattr(self, '_current_appid', '') or '', } return # Calculate time taken time_taken = self._calculate_time_taken() - # Clear Activity window before showing success dialog - self.file_progress_list.clear() - - success_dialog = SuccessDialog( - modlist_name=modlist_name, - workflow_type="configure_new", - time_taken=time_taken, - game_name=getattr(self, '_current_game_name', None), - parent=self + game_type = self._detect_game_type_from_mo2_ini(install_dir) if install_dir else "unknown" + self._run_verifier_then_show_success( + install_dir=install_dir or "", + game_type=game_type, + success_params={ + 'modlist_name': modlist_name, + 'workflow_type': 'configure_new', + 'time_taken': time_taken, + 'game_name': getattr(self, '_current_game_name', None), + 'enb_detected': enb_detected, + }, ) - success_dialog.show() - - if enb_detected: - try: - from ..dialogs.enb_proton_dialog import ENBProtonDialog - enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self) - enb_dialog.exec() - except Exception as e: - logger.warning("Failed to show ENB dialog: %s", e) else: self._safe_append_text(f"Configuration failed: {message}") MessageService.show_error(self, configuration_failed(str(message))) diff --git a/jackify/frontends/gui/screens/configure_new_modlist_console.py b/jackify/frontends/gui/screens/configure_new_modlist_console.py index 2738984..0ca0516 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_console.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_console.py @@ -15,6 +15,51 @@ class ConfigureNewModlistConsoleMixin(FocusReclaimMixin): def _handle_progress_update(self, text): """Handle progress updates - update console, activity window, and progress indicator.""" + if text.startswith("[NATIVE_DL] "): + parts = text.split(None, 3) + if len(parts) == 4: + _, component, pct_str, speed_str = parts + try: + pct, speed = float(pct_str), float(speed_str) + self.file_progress_list.update_or_add_item( + "__native_component__", + f"Wine component: {component} | {pct:.0f}% ({speed:.1f} MB/s)", + pct, + ) + except ValueError: + pass + return + if text.startswith("[NATIVE_INSTALL] "): + component = text[17:].strip() + self._stop_component_install_pulse() + done = getattr(self, '_native_done_components', 0) + total = getattr(self, '_native_total_components', 0) + remaining = max(0, total - done) + suffix = f" ({remaining} remaining)" if remaining > 0 else "" + self.file_progress_list.update_or_add_item( + "__native_component__", + f"Wine component: {component}{suffix}", + 0.0, + ) + self.progress_indicator.set_status(f"Installing {component}...", 80) + self._current_native_component = component + self._native_done_components = done + 1 + return + if text.startswith("[NATIVE_WAIT] "): + parts = text.split(None, 2) + if len(parts) >= 3: + component, elapsed_s = parts[1], parts[2].strip() + done = getattr(self, '_native_done_components', 1) + total = getattr(self, '_native_total_components', 0) + remaining = max(0, total - done) + suffix = f" ({remaining} remaining)" if remaining > 0 else "" + self.file_progress_list.update_or_add_item( + "__native_component__", + f"Wine component: {component} | {elapsed_s}s{suffix}", + 0.0, + ) + self.progress_indicator.set_status(f"Installing {component}... ({elapsed_s}s)", 80) + return self._safe_append_text(text) message_lower = text.lower() @@ -47,11 +92,17 @@ class ConfigureNewModlistConsoleMixin(FocusReclaimMixin): self.file_progress_list.update_or_add_item("__phase__", "Applying registry...", 0.0) elif "installing wine components" in message_lower or "wine component" in message_lower: self.progress_indicator.set_status("Installing wine components...", 80) - if not hasattr(self, '_component_install_timer') or not self._component_install_timer: - self._start_component_install_pulse() comp_list = self._parse_wine_components_message(text) if comp_list: - self._start_component_install_pulse_with_components(comp_list) + self._native_total_components = len(comp_list) + self._native_done_components = 0 + self.file_progress_list.update_or_add_item( + "__native_component__", + f"Wine components: {len(comp_list)} queued", + 0.0, + ) + elif not hasattr(self, '_component_install_timer') or not self._component_install_timer: + self._start_component_install_pulse() elif "wine components verified" in message_lower or "wine components installed" in message_lower: self._stop_component_install_pulse() self.progress_indicator.set_status("Wine components installed", 85) diff --git a/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py b/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py index 3dca77a..cb41a82 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py @@ -234,26 +234,18 @@ class ConfigureNewModlistDialogsMixin: if hasattr(self, '_pending_success_dialog_params'): params = self._pending_success_dialog_params del self._pending_success_dialog_params - - self.file_progress_list.clear() - - from ..dialogs import SuccessDialog - success_dialog = SuccessDialog( - modlist_name=params['modlist_name'], - workflow_type=params['workflow_type'], - time_taken=params['time_taken'], - game_name=params['game_name'], - parent=self, + self._run_verifier_then_show_success( + install_dir=params.get('install_dir', ''), + game_type=params.get('game_type', 'unknown'), + appid=params.get('appid', ''), + success_params={ + 'modlist_name': params['modlist_name'], + 'workflow_type': params['workflow_type'], + 'time_taken': params['time_taken'], + 'game_name': params.get('game_name'), + 'enb_detected': params.get('enb_detected', False), + }, ) - success_dialog.show() - - if params.get('enb_detected'): - try: - from ..dialogs.enb_proton_dialog import ENBProtonDialog - enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self) - enb_dialog.exec() - except Exception as e: - logger.warning("Failed to show ENB dialog: %s", e) def show_next_steps_dialog(self, message): dlg = QDialog(self) diff --git a/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py b/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py index 0fba309..c359df9 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py @@ -18,7 +18,7 @@ class ConfigureNewModlistUISetupMixin: def __init__(self, stacked_widget=None, main_menu_index=0, dev_mode=False, system_info=None): super().__init__() - logger.debug("DEBUG: ConfigureNewModlistScreen __init__ called") + logger.debug("ConfigureNewModlistScreen __init__ called") self.stacked_widget = stacked_widget self.main_menu_index = main_menu_index self.dev_mode = dev_mode @@ -178,7 +178,7 @@ class ConfigureNewModlistUISetupMixin: combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) self.resolution_combo.setCurrentIndex(resolution_index) - logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") + logger.debug(f"Loaded saved resolution: {saved_resolution} (index: {resolution_index})") elif is_steam_deck: # Set default to 1280x800 (Steam Deck) combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] diff --git a/jackify/frontends/gui/screens/configure_new_modlist_workflow.py b/jackify/frontends/gui/screens/configure_new_modlist_workflow.py index ee17ee9..48f856b 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_workflow.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_workflow.py @@ -95,14 +95,14 @@ class ConfigureNewModlistWorkflowMixin: if resolution and resolution != "Leave unchanged": success = self.resolution_service.save_resolution(resolution) if success: - logger.debug(f"DEBUG: Resolution saved successfully: {resolution}") + logger.debug(f"Resolution saved successfully: {resolution}") else: - logger.debug("DEBUG: Failed to save resolution") + logger.debug("Failed to save resolution") else: # Clear saved resolution if "Leave unchanged" is selected if self.resolution_service.has_saved_resolution(): self.resolution_service.clear_saved_resolution() - logger.debug("DEBUG: Saved resolution cleared") + logger.debug("Saved resolution cleared") # Start configuration - automated workflow handles Steam restart internally self.configure_modlist() @@ -134,7 +134,7 @@ class ConfigureNewModlistWorkflowMixin: ensure_flatpak_steam_filesystem_access(Path(install_dir)) from jackify import __version__ as jackify_version logger.info("Jackify v%s", jackify_version) - logger.info("Initializing automated Steam setup for '%s'...", modlist_name) + logger.info("Initialising automated Steam setup for '%s'...", modlist_name) logger.info("Starting automated Steam shortcut creation and configuration...") # Disable the start button to prevent multiple workflows @@ -162,9 +162,13 @@ class ConfigureNewModlistWorkflowMixin: def progress_callback(message): self.progress_update.emit(message) + from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir + download_dir = resolve_mo2_download_dir(Path(self.install_dir)) + result = prefix_service.run_working_workflow( self.modlist_name, self.install_dir, self.mo2_exe_path, - progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart + progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart, + download_dir=download_dir, ) self.workflow_complete.emit(result) @@ -304,8 +308,7 @@ class ConfigureNewModlistWorkflowMixin: 'modlist_source': None, 'resolution': resolution_value, 'skip_confirmation': True, - 'manual_steps_completed': True, # Mark as completed since automated prefix is done - 'appid': new_appid, # Use the NEW AppID from automated prefix creation + 'appid': new_appid, 'game_name': 'Skyrim Special Edition' # Default for new modlist } self.context = updated_context # Ensure context is always set @@ -364,11 +367,6 @@ class ConfigureNewModlistWorkflowMixin: def completion_callback(success, message, modlist_name, enb_detected=False): self.configuration_complete.emit(success, message, modlist_name, enb_detected) - def manual_steps_callback(modlist_name, retry_count): - # This shouldn't happen since automated prefix creation is complete - self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") - - # Call the service method for post-Steam configuration self.progress_update.emit("") self.progress_update.emit("=== Configuration Phase ===") self.progress_update.emit("") @@ -376,7 +374,6 @@ class ConfigureNewModlistWorkflowMixin: result = modlist_service.configure_modlist_post_steam( context=modlist_context, progress_callback=progress_callback, - manual_steps_callback=manual_steps_callback, completion_callback=completion_callback ) @@ -412,8 +409,7 @@ class ConfigureNewModlistWorkflowMixin: 'mo2_exe_path': mo2_exe_path, 'resolution': resolution.split()[0] if resolution != "Leave unchanged" else None, 'skip_confirmation': True, - 'manual_steps_completed': True, # Mark as completed - 'appid': new_appid, # Use the NEW AppID from Steam + 'appid': new_appid, 'game_name': 'Skyrim Special Edition' # Default for new modlist } logger.debug(f"Updated context with new AppID: {new_appid}") @@ -472,17 +468,11 @@ class ConfigureNewModlistWorkflowMixin: def completion_callback(success, message, modlist_name, enb_detected=False): self.configuration_complete.emit(success, message, modlist_name, enb_detected) - def manual_steps_callback(modlist_name, retry_count): - # Should not reach here -- manual steps already complete - self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") - - # Call the working configuration service method self.progress_update.emit("Starting configuration with backend service...") - + success = modlist_service.configure_modlist_post_steam( context=modlist_context, progress_callback=progress_callback, - manual_steps_callback=manual_steps_callback, completion_callback=completion_callback ) diff --git a/jackify/frontends/gui/screens/install_modlist.py b/jackify/frontends/gui/screens/install_modlist.py index e52df4b..ee6f086 100644 --- a/jackify/frontends/gui/screens/install_modlist.py +++ b/jackify/frontends/gui/screens/install_modlist.py @@ -49,9 +49,10 @@ from .install_modlist_workflow import InstallWorkflowMixin from .install_modlist_nexus import NexusAuthMixin from .install_modlist_selection import ModlistSelectionMixin from .screen_back_mixin import ScreenBackMixin +from .install_verifier_mixin import InstallVerifierMixin from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin -class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin): +class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifierMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin): resize_request = Signal(str) # Signal for expand/collapse like TTW screen def _collect_actionable_controls(self): """Collect all actionable controls that should be disabled during operations (except Cancel)""" @@ -78,6 +79,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist self.nexus_login_btn, # Checkboxes self.auto_restart_checkbox, + self.engine_checkbox, ] def _disable_controls_during_operation(self): @@ -113,12 +115,13 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True) def _open_url_safe(self, url): - """Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime""" import subprocess + _strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"} + clean_env = {k: v for k, v in os.environ.items() if k not in _strip} try: - subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.Popen(['xdg-open', url], env=clean_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) except Exception as e: - print(f"Warning: Could not open URL {url}: {e}") + logger.warning(f"Could not open URL {url}: {e}") def resizeEvent(self, event): """Handle window resize to prioritize form over console""" @@ -216,7 +219,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist set_responsive_minimum(main_window, min_width=960, min_height=420) # DO NOT resize - let window stay at current size except Exception as e: - logger.debug(f"DEBUG: showEvent exception: {e}") + logger.debug(f"showEvent exception: {e}") def _start_gallery_cache_preload(self): """DEPRECATED: Gallery cache preload now happens at app startup in JackifyMainWindow""" @@ -248,22 +251,22 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist # Check if we got mods modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods) if modlists_with_mods > 0: - logger.debug(f"DEBUG: Gallery cache ready ({modlists_with_mods} modlists with mods)") + logger.debug(f"Gallery cache ready ({modlists_with_mods} modlists with mods)") else: # Cache didn't have mods, but we fetched fresh - should have mods now - logger.debug("DEBUG: Gallery cache updated") + logger.debug("Gallery cache updated") else: - logger.debug("DEBUG: Failed to load gallery cache") + logger.debug("Failed to load gallery cache") except Exception as e: - logger.debug(f"DEBUG: Gallery cache preload error: {str(e)}") + logger.debug(f"Gallery cache preload error: {str(e)}") # Start thread (non-blocking, invisible to user) self._gallery_cache_preload_thread = GalleryCachePreloadThread() # Don't connect finished signal - we don't need to do anything, just let it run self._gallery_cache_preload_thread.start() - logger.debug("DEBUG: Started background gallery cache preload") + logger.debug("Started background gallery cache preload") def hideEvent(self, event): """Called when the widget is hidden. Do not clear main window constraints so collapse from go_back() sticks.""" @@ -284,17 +287,17 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist if saved_install_parent: suggested_install_dir = os.path.join(saved_install_parent, modlist_name) self.install_dir_edit.setText(suggested_install_dir) - logger.debug(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}") + logger.debug(f"Updated install directory suggestion: {suggested_install_dir}") # Update download directory suggestion saved_download_parent = self.config_handler.get_default_download_parent_dir() if saved_download_parent: suggested_download_dir = os.path.join(saved_download_parent, "Downloads") self.downloads_dir_edit.setText(suggested_download_dir) - logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}") + logger.debug(f"Updated download directory suggestion: {suggested_download_dir}") except Exception as e: - logger.debug(f"DEBUG: Error updating directory suggestions: {e}") + logger.debug(f"Error updating directory suggestions: {e}") def _save_parent_directories(self, install_dir, downloads_dir): """Removed automatic saving - user should set defaults in settings""" @@ -422,6 +425,15 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist def cleanup_processes(self): """Clean up any running processes when the window closes or is cancelled""" + self._stop_clf3_decompress_pulse() + + fpl = getattr(self, 'file_progress_list', None) + if fpl is not None: + try: + fpl.stop_cpu_tracking() + except Exception: + pass + if getattr(self, '_vnv_controller', None) is not None: self._vnv_controller.cleanup() self._vnv_controller = None @@ -446,7 +458,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist setattr(self, attr_name, None) return - logger.debug(f"DEBUG: Stopping {attr_name}") + logger.debug(f"Stopping {attr_name}") if cancel_method and hasattr(thread, cancel_method): try: @@ -543,13 +555,16 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist self._pending_manual_download_events = None # Cancel the installation thread if it exists - if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning(): - self.install_thread.cancel() - self.install_thread.wait(12000) # Allow time for child processes (7zz) to die; no terminate() - pthread_cancel corrupts Python - if self.install_thread.isRunning(): - logger.warning("WARNING: InstallationThread still running after 12s cancel wait; retrying") + try: + if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning(): self.install_thread.cancel() - self.install_thread.wait(5000) + self.install_thread.wait(12000) # Allow time for child processes (7zz) to die; no terminate() - pthread_cancel corrupts Python + if self.install_thread.isRunning(): + logger.warning("WARNING: InstallationThread still running after 12s cancel wait; retrying") + self.install_thread.cancel() + self.install_thread.wait(5000) + except RuntimeError: + self.install_thread = None # Park prefix/config threads - disconnect their signals and let them # finish naturally rather than terminating unsafely. @@ -559,6 +574,16 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist ["progress_update", "workflow_complete", "error_occurred"], ) if hasattr(self, 'config_thread') and self.config_thread: + _ctx = getattr(self, 'context', None) + _appid = str( + getattr(self, '_current_appid', '') + or ((_ctx.get('appid', '') if isinstance(_ctx, dict) else '') or '') + ) + try: + self.config_thread.requestInterruption() + except Exception: + pass + self._kill_prefix_wine_processes(_appid) self.config_thread = self._park_thread( self.config_thread, ["progress_update", "configuration_complete", "error_occurred"], diff --git a/jackify/frontends/gui/screens/install_modlist_automated_prefix.py b/jackify/frontends/gui/screens/install_modlist_automated_prefix.py index bded83a..1d9f234 100644 --- a/jackify/frontends/gui/screens/install_modlist_automated_prefix.py +++ b/jackify/frontends/gui/screens/install_modlist_automated_prefix.py @@ -160,11 +160,11 @@ class AutomatedPrefixHandlersMixin: self.prefix_thread.start() except Exception as e: - logger.debug(f"DEBUG: Exception in start_automated_prefix_workflow: {e}") - logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}") + logger.debug(f"Exception in start_automated_prefix_workflow: {e}") self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}") - # Re-enable controls on exception self._enable_controls_after_operation() + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None): """Handle completion of automated prefix creation""" diff --git a/jackify/frontends/gui/screens/install_modlist_configuration.py b/jackify/frontends/gui/screens/install_modlist_configuration.py index bc6d411..c7b5175 100644 --- a/jackify/frontends/gui/screens/install_modlist_configuration.py +++ b/jackify/frontends/gui/screens/install_modlist_configuration.py @@ -5,7 +5,6 @@ from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL from PySide6.QtGui import QFont from jackify.frontends.gui.services.message_service import MessageService from jackify.shared.errors import manual_steps_incomplete, configuration_failed -from jackify.frontends.gui.dialogs import SuccessDialog from jackify.backend.handlers.validation_handler import ValidationHandler from jackify.backend.models.modlist import ModlistContext from pathlib import Path @@ -22,7 +21,8 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix def on_configuration_progress(self, progress_msg): """Handle progress updates from modlist configuration""" - self._safe_append_text(progress_msg) + if not (progress_msg.startswith("[NATIVE_DL] ") or progress_msg.startswith("[NATIVE_INSTALL] ") or progress_msg.startswith("[NATIVE_WAIT] ")): + self._safe_append_text(progress_msg) self._handle_post_install_progress(progress_msg) def show_steam_restart_progress(self, message): @@ -90,7 +90,6 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix if self._show_somnium_guidance: self._show_somnium_post_install_guidance() - # Show celebration SuccessDialog after the entire workflow if not hasattr(self, '_install_workflow_start_time'): self._install_workflow_start_time = time.time() time_taken = int(time.time() - self._install_workflow_start_time) @@ -147,15 +146,16 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix if vnv_automation_running: self._cleanup_config_thread() - # Store success dialog params for later (after VNV automation completes) self._pending_success_dialog_params = { 'modlist_name': modlist_name, + 'workflow_type': "update" if getattr(self, "_is_update_install", False) else "install", 'time_taken': time_str, 'game_name': game_name, - 'enb_detected': enb_detected + 'enb_detected': enb_detected, + 'install_dir': install_dir, + 'game_type': getattr(self, '_current_game_type', 'unknown') or 'unknown', + 'appid': getattr(self, '_current_appid', '') or '', } - # Keep post-install feedback active during VNV automation - # Don't show success dialog yet - will be shown in _on_vnv_complete return # No VNV automation - end post-install feedback now @@ -167,29 +167,19 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix except Exception as e: logger.warning("Update mode verify: failed post-config INI verification: %s", e) - # Clear Activity window before showing success dialog - self.file_progress_list.clear() - - # Show normal success dialog workflow_type = "update" if getattr(self, "_is_update_install", False) else "install" - success_dialog = SuccessDialog( - modlist_name=modlist_name, - workflow_type=workflow_type, - time_taken=time_str, - game_name=game_name, - parent=self + game_type_for_verify = getattr(self, '_current_game_type', 'unknown') or 'unknown' + self._run_verifier_then_show_success( + install_dir=install_dir, + game_type=game_type_for_verify, + success_params={ + 'modlist_name': modlist_name, + 'workflow_type': workflow_type, + 'time_taken': time_str, + 'game_name': game_name, + 'enb_detected': enb_detected, + }, ) - success_dialog.show() - - # Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection) - if enb_detected: - try: - from ..dialogs.enb_proton_dialog import ENBProtonDialog - enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self) - enb_dialog.exec() # Modal dialog - blocks until user clicks OK - except Exception as e: - # Non-blocking: if dialog fails, just log and continue - logger.warning(f"Failed to show ENB dialog: {e}") elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3: # Max retries reached - show failure message self._end_post_install_feedback(False) @@ -426,8 +416,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix 'modlist_source': None, 'resolution': getattr(self, '_current_resolution', None), 'skip_confirmation': True, - 'manual_steps_completed': True, # Mark as completed since automated prefix is done - 'appid': new_appid, # Use the NEW AppID from automated prefix creation + 'appid': new_appid, 'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition' } self.context = updated_context # Ensure context is always set @@ -490,15 +479,9 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix def completion_callback(success, message, modlist_name, enb_detected=False): self.configuration_complete.emit(success, message, modlist_name, enb_detected) - def manual_steps_callback(modlist_name, retry_count): - # Should not reach here -- prefix creation already complete - self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") - - # Call the service method for post-Steam configuration result = modlist_service.configure_modlist_post_steam( context=modlist_context, progress_callback=progress_callback, - manual_steps_callback=manual_steps_callback, completion_callback=completion_callback ) @@ -533,8 +516,8 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix 'modlist_source': None, 'resolution': getattr(self, '_current_resolution', None), 'skip_confirmation': True, - 'manual_steps_completed': True, # Mark as completed - 'appid': new_appid # Use the NEW AppID from Steam + 'appid': new_appid + } logger.debug(f"Updated context with new AppID: {new_appid}") @@ -624,15 +607,9 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix def completion_callback(success, message, modlist_name): self.configuration_complete.emit(success, message, modlist_name) - def manual_steps_callback(modlist_name, retry_count): - # Should not reach here -- manual steps already complete - self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") - - # Call the new service method for post-Steam configuration result = modlist_service.configure_modlist_post_steam( context=modlist_context, progress_callback=progress_callback, - manual_steps_callback=manual_steps_callback, completion_callback=completion_callback ) diff --git a/jackify/frontends/gui/screens/install_modlist_installer_thread.py b/jackify/frontends/gui/screens/install_modlist_installer_thread.py index bf450cd..3dce178 100644 --- a/jackify/frontends/gui/screens/install_modlist_installer_thread.py +++ b/jackify/frontends/gui/screens/install_modlist_installer_thread.py @@ -12,7 +12,7 @@ from typing import Optional from PySide6.QtCore import QThread, Signal import logging -from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code +from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code, nexus_url_from_error_line from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename, is_creation_kit_missing_error from jackify.shared.errors import JackifyError, cc_content_missing, creation_kit_missing @@ -35,14 +35,17 @@ class InstallerThread(QThread): def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name, install_mode='online', progress_state_manager=None, auth_service=None, - oauth_info=None): + oauth_info=None, game_type=None, clf3_cdn_url=None, engine_id=None): super().__init__() self.modlist = modlist self.install_dir = install_dir self.downloads_dir = downloads_dir self.api_key = api_key self.modlist_name = modlist_name + self.game_type = game_type self.install_mode = install_mode + self.clf3_cdn_url = clf3_cdn_url + self.engine_id = engine_id self.cancelled = False self.process_manager = None self.progress_state_manager = progress_state_manager @@ -126,16 +129,58 @@ class InstallerThread(QThread): return False + # CLF3 tracing line patterns (after ANSI stripping, from tracing_subscriber::fmt default format) + _CLF3_TRACING_INFO_RE = re.compile( + r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s+(INFO|DEBUG|TRACE)\s+' + ) + _CLF3_TRACING_WARN_RE = re.compile( + r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s+(WARN|ERROR)\s+\S+:\s+(.*)' + ) + _CLF3_PHASE_HEADER_RE = re.compile(r'^=== .+ ===$') + _CLF3_ANSI_RE = re.compile(r'\x1b\[[0-9;?]*[ -/]*[@-~]') + def _read_stderr(self): + import time as _time + _stderr_log = os.environ.get("JACKIFY_CLF3_VERBOSE") + _stderr_fh = open("/tmp/clf3_stderr.log", "w", encoding="utf-8") if _stderr_log else None + _last_clf3_phase: str = '' try: for raw in self.process_manager.proc.stderr: line = raw.decode('utf-8', errors='replace').strip() if not line: continue logger.debug(f"Engine stderr: {line}") + if _stderr_fh: + _stderr_fh.write(line + "\n") + _stderr_fh.flush() self._raw_stderr_lines.append(line) if len(self._raw_stderr_lines) > 40: self._raw_stderr_lines.pop(0) + + # CLF3: JSON events are on stdout; human-readable detail and tracing go to stderr. + # Forward phase headers and log() messages to Show Details. + # Verbose INFO/DEBUG/TRACE tracing is dropped; WARN/ERROR is shown stripped. + clf3_parser = getattr(self, '_clf3_parser', None) + if clf3_parser is not None: + clean = self._CLF3_ANSI_RE.sub('', line) + error = parse_engine_error_line(clean) + if error and self.last_error is None: + self.last_error = error + warn_m = self._CLF3_TRACING_WARN_RE.match(clean) + if warn_m: + self.output_received.emit(f"[WARN] {warn_m.group(2)}\n") + elif not self._CLF3_TRACING_INFO_RE.match(clean): + if self._CLF3_PHASE_HEADER_RE.match(clean): + if clean != _last_clf3_phase: + _last_clf3_phase = clean + self.output_received.emit(clean + '\n') + else: + self.output_received.emit(clean + '\n') + _act = getattr(self, '_clf3_last_activity', None) + if _act is not None: + _act[0] = _time.monotonic() + continue + error = parse_engine_error_line(line) if error and self.last_error is None: self.last_error = error @@ -149,9 +194,13 @@ class InstallerThread(QThread): if self.last_error is None and is_cc_content_error(line): self.last_error = cc_content_missing(extract_cc_filename(line) or "") if self.last_error is None and is_creation_kit_missing_error(line): - self.last_error = creation_kit_missing() + self.last_error = creation_kit_missing(self.game_type) except Exception as e: logger.debug(f"Stderr reader error: {e}") + finally: + if _stderr_fh: + _stderr_fh.close() + logger.info("CLF3 stderr log written to /tmp/clf3_stderr.log") def _remember_stdout_line(self, line: str) -> None: """Keep a bounded tail of meaningful stdout lines for failure diagnostics.""" @@ -214,6 +263,9 @@ class InstallerThread(QThread): """Build a user-facing failure message with the best available root cause.""" root_cause = self._extract_root_cause_line() if root_cause: + nexus_url = nexus_url_from_error_line(root_cause) + if nexus_url: + root_cause = f"{root_cause}\nMod page: {nexus_url}" if self._resource_limit_hint and "file descriptor" not in root_cause.lower(): return f"{root_cause}\n\nPossible contributing issue: {self._resource_limit_hint}" return root_cause @@ -251,42 +303,195 @@ class InstallerThread(QThread): "Install failed, but the engine did not provide a specific error line." ) + def _run_clf3_heartbeat(self, last_activity: list, stop: threading.Event) -> None: + """Emit periodic 'finalising' updates to Show Details when stdout goes silent. + + Fires once when silence exceeds THRESHOLD, then repeats every REPEAT seconds + of continued silence — so extended waits remain visible to the user. + Samples /proc//io to show write throughput, giving the user concrete + evidence that extraction is progressing even when CLF3 emits no output. + Resets when real output arrives so a new silence period can trigger it again. + """ + import copy + import time as _time + THRESHOLD = 8.0 + INTERVAL = 5.0 + REPEAT = 30.0 + last_heartbeat_at = [0.0] + while not stop.wait(INTERVAL): + proc = self.process_manager.proc if self.process_manager else None + if not proc or proc.poll() is not None: + break + now = _time.monotonic() + elapsed_since_activity = now - last_activity[0] + if elapsed_since_activity < THRESHOLD: + continue + # Allow first fire when silence starts; then only re-fire every REPEAT seconds. + already_fired = last_heartbeat_at[0] >= last_activity[0] + if already_fired and (now - last_heartbeat_at[0]) < REPEAT: + continue + clf3_parser = getattr(self, '_clf3_parser', None) + phase = (clf3_parser.get_state().phase_name if clf3_parser else None) or 'Working' + wait_secs = int(elapsed_since_activity) + # Sample write_bytes to show active extraction throughput. + # The N/N dispatch counter fires when all archives are handed to worker threads; + # the workers themselves continue decompressing in parallel after that point. + # /proc//io write_bytes confirms real I/O is happening. + if not already_fired: + total = clf3_parser.get_state().phase_max_steps if clf3_parser else 0 + archive_str = f"{total} archives" if total else "archives" + self.output_received.emit(f"[Decompressing {archive_str}: workers running...]\n") + if clf3_parser: + from jackify.shared.progress_models import InstallationPhase + current = clf3_parser.get_state() + heartbeat_state = copy.copy(current) + heartbeat_state.phase = InstallationPhase.FINALIZE + heartbeat_state.phase_name = "Decompressing" + heartbeat_state.phase_step = 0 + heartbeat_state.phase_max_steps = 0 + heartbeat_state.overall_percent = 99.0 + heartbeat_state.message = "Decompressing archives..." + self.progress_updated.emit(heartbeat_state) + last_heartbeat_at[0] = now + + def _run_clf3_stdout_loop(self, last_activity: list) -> None: + """Read CLF3 stdout line-by-line and forward to Show Details. + + JSON progress events (keyed on "type") and plain human-readable text both arrive + on stdout. Manual download events (keyed on "event") also appear on stdout. + + Extraction dispatch counters ("Extracting: N/M") are dropped — named per-archive + completion lines already cover this information. + Directive counters ("Processing: N/M") are buffered; only the final value is + emitted when the next non-counter line arrives, avoiding a 1315-line flood. + """ + import time as _time + _COUNTER_LINE_RE = re.compile(r'^(?:Extracting|Building BSA|DDS Transform): \d+/\d+$') + _PROCESSING_RE = re.compile(r'^Processing: \d+/\d+$') + _PHASE_HEADER_RE = re.compile(r'^=== .+ ===$') + _buffered_processing = None + _last_phase_header: str = '' + ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]') + for raw in self.process_manager.proc.stdout: + if self.cancelled: + self.cancel() + break + decoded = ansi_escape.sub(b'', raw).decode('utf-8', errors='replace').rstrip('\r\n') + stripped = decoded.strip() + if not stripped: + continue + last_activity[0] = _time.monotonic() + self._remember_stdout_line(decoded) + if self._handle_engine_event(decoded): + continue + if stripped.startswith('{'): + clf3_parser = getattr(self, '_clf3_parser', None) + if clf3_parser and clf3_parser.process_line(stripped): + state = clf3_parser.get_state() + self.progress_updated.emit(state) + msg = state.message + if msg.startswith('Extracting ') and '(' in msg and state.phase_name == "Extracting": + # Relabel as "Queuing" — the N/M counter tracks archive dispatch + # to worker threads, not completion of decompression. + self.output_received.emit(msg.replace('Extracting ', 'Queuing ', 1) + '\n') + continue + if _COUNTER_LINE_RE.match(stripped): + continue + if _PROCESSING_RE.match(stripped): + _buffered_processing = stripped + continue + if _buffered_processing is not None: + self.output_received.emit(_buffered_processing + '\n') + _buffered_processing = None + if _PHASE_HEADER_RE.match(stripped): + if stripped == _last_phase_header: + continue + _last_phase_header = stripped + self.output_received.emit(decoded + '\n') + if _buffered_processing is not None: + self.output_received.emit(_buffered_processing + '\n') + def run(self): try: - from jackify.backend.core.modlist_operations import get_jackify_engine_path - engine_path = get_jackify_engine_path() - if not os.path.exists(engine_path): - error_msg = f"Engine not found at: {engine_path}" - logger.debug(f"DEBUG: {error_msg}") + from jackify.backend.services.engine_invoker import ( + get_active_engine_id, get_engine_path, build_install_command, + resolve_game_dir, resolve_game_location, + ) + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + + engine_id = self.engine_id if self.engine_id else get_active_engine_id() + engine_path = get_engine_path(engine_id) + if not engine_path or not os.path.exists(engine_path): + error_msg = f"Engine not found: {engine_id} ({engine_path or 'path unknown'})" + logger.error(error_msg) self.installation_finished.emit(False, error_msg) return if not os.access(engine_path, os.X_OK): error_msg = f"Engine is not executable: {engine_path}" - logger.debug(f"DEBUG: {error_msg}") + logger.error(error_msg) self.installation_finished.emit(False, error_msg) return - logger.debug(f"DEBUG: Using engine at: {engine_path}") - if self.install_mode == 'file': - cmd = [engine_path, "install", "--show-file-progress", "-w", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] - else: - cmd = [engine_path, "install", "--show-file-progress", "-m", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() + + logger.debug(f"Using engine {engine_id} at: {engine_path}") + debug_mode = config_handler.get('debug_mode', False) - if debug_mode: - cmd.append('--debug') - logger.debug("DEBUG: Added --debug flag to jackify-engine command") - logger.debug(f"DEBUG: FULL Engine command: {' '.join(cmd)}") - logger.debug(f"DEBUG: modlist value being passed: '{self.modlist}'") + game_dir = None + clf3_mode = (engine_id == "clf3") + if clf3_mode: + location = resolve_game_location(self.game_type) + if location: + game_dir, game_store = location + if game_store != 'steam': + store_label = {'gog': 'GOG', 'epic': 'Epic Games'}.get(game_store, game_store) + self.output_received.emit( + f"[WARN] Game detected from {store_label}, not Steam. " + "Most Wabbajack modlists require the Steam version. " + "If the install fails with hash errors, a store version mismatch is likely the cause.\n" + ) + else: + logger.warning("CLF3: could not resolve game directory for game_type=%s", self.game_type) + + if clf3_mode and self.clf3_cdn_url and not os.path.isfile(self.modlist): + self.output_received.emit("Downloading modlist file via CLF3...\n") + import subprocess as _sp + from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env + fetch_cmd = [engine_path, "fetch", self.clf3_cdn_url, "--output", self.modlist] + logger.debug("CLF3 fetch command: %s", " ".join(fetch_cmd)) + fetch_env = get_clean_subprocess_env({}) + fetch_cwd = os.path.dirname(self.modlist) or os.path.expanduser("~") + os.makedirs(fetch_cwd, exist_ok=True) + fetch_result = _sp.run(fetch_cmd, capture_output=True, text=True, env=fetch_env, cwd=fetch_cwd) + if fetch_result.returncode != 0: + err = fetch_result.stderr.strip() or fetch_result.stdout.strip() or "unknown error" + self.installation_finished.emit(False, f"Failed to download modlist file:\n\n{err}") + return + self.output_received.emit("Modlist file ready.\n") + + cmd = build_install_command( + engine_id=engine_id, + engine_path=engine_path, + wabbajack=self.modlist, + install_dir=self.install_dir, + downloads_dir=self.downloads_dir, + game_dir=game_dir, + install_mode=self.install_mode, + debug=debug_mode, + ) + logger.debug(f"FULL Engine command: {' '.join(cmd)}") + logger.debug(f"modlist value being passed: '{self.modlist}'") from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env writeback_path = str(self.auth_service.get_token_writeback_path()) if self.auth_service else None - env_vars = {'NEXUS_API_KEY': self.api_key} - if self.oauth_info: - env_vars['NEXUS_OAUTH_INFO'] = self.oauth_info - from jackify.backend.services.nexus_oauth_service import NexusOAuthService - env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID - if writeback_path: - env_vars['JACKIFY_TOKEN_WRITEBACK'] = writeback_path + if clf3_mode: + env_vars = {'NEXUS_OAUTH_TOKEN': self.api_key} + else: + env_vars = {'NEXUS_API_KEY': self.api_key} + if self.oauth_info: + env_vars['NEXUS_OAUTH_INFO'] = self.oauth_info + from jackify.backend.services.nexus_oauth_service import NexusOAuthService + env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID + if writeback_path: + env_vars['JACKIFY_TOKEN_WRITEBACK'] = writeback_path env = get_clean_subprocess_env(env_vars) # Install-time resource preflight: keep this visible in workflow output so @@ -306,172 +511,188 @@ class InstallerThread(QThread): logger.debug(f"Resource preflight check failed: {e}") from jackify.backend.handlers.subprocess_utils import ProcessManager + if clf3_mode: + import time as _time + from jackify.backend.handlers.progress_parser_clf3 import CLF3ProgressStateManager + self._clf3_parser = CLF3ProgressStateManager() + self._clf3_last_activity = [_time.monotonic()] + else: + self._clf3_parser = None self.process_manager = ProcessManager(cmd, env=env, text=False, separate_stderr=True, enable_stdin=True) stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) stderr_thread.start() - ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]') - buffer = b'' - last_was_blank = False - while True: - if self.cancelled: - self.cancel() - break - char = self.process_manager.read_stdout_char() - if not char: - break - buffer += char - while b'\n' in buffer or b'\r' in buffer: - if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True): - line, buffer = buffer.split(b'\r', 1) - line = ansi_escape.sub(b'', line) - decoded = line.decode('utf-8', errors='replace') - config_handler = ConfigHandler() - debug_mode = config_handler.get('debug_mode', False) - from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator - is_premium_error, matched_pattern = is_non_premium_indicator(decoded) - if not self._premium_signal_sent and is_premium_error: - self._premium_signal_sent = True - logger.warning("=" * 80) - logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)") - logger.warning("=" * 80) - logger.warning(f"Matched pattern: '{matched_pattern}'") - logger.warning(f"Triggering line: '{decoded.strip()}'") - logger.warning("AUTHENTICATION DIAGNOSTICS:") - logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}") - if self.api_key: - logger.warning(f" Auth value length: {len(self.api_key)} chars") - if len(self.api_key) >= 8: - logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}") - auth_method = self.auth_service.get_auth_method() if self.auth_service else None - logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}") - if auth_method == 'oauth' and self.auth_service: - token_handler = self.auth_service.token_handler - token_info = token_handler.get_token_info() - logger.warning(" OAuth Token Status:") - logger.warning(f" Has token file: {token_info.get('has_token', False)}") - logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}") - if 'expires_in_minutes' in token_info: - logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes") - if 'refresh_token_age_days' in token_info: - logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days") - if token_info.get('error'): - logger.warning(f" Error: {token_info['error']}") - logger.warning("Previous engine output (last 10 lines):") - for i, buffered_line in enumerate(self._engine_output_buffer, 1): - logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}") - logger.warning("If user HAS Premium, this is a FALSE POSITIVE") - logger.warning("=" * 80) - self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") - self._engine_output_buffer.append(decoded.strip()) - if len(self._engine_output_buffer) > self._buffer_size: - self._engine_output_buffer.pop(0) - if self.last_error is None and is_cc_content_error(decoded): - self.last_error = cc_content_missing(extract_cc_filename(decoded) or "") - if self.last_error is None and is_creation_kit_missing_error(decoded): - self.last_error = creation_kit_missing() - if self.progress_state_manager: - updated = self.progress_state_manager.process_line(decoded) - if updated: - progress_state = self.progress_state_manager.get_state() - if progress_state.active_files and debug_mode: - logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}") - self.progress_updated.emit(progress_state) - if '[FILE_PROGRESS]' in decoded: - self._install_progress_started = True - parts = decoded.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - self.progress_received.emit(parts[0].rstrip()) - else: - self.progress_received.emit(decoded + '\r') - elif b'\n' in buffer: - line, buffer = buffer.split(b'\n', 1) - line = ansi_escape.sub(b'', line) - decoded = line.decode('utf-8', errors='replace') - from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator - is_premium_error, matched_pattern = (False, None) if decoded.strip().startswith('{') else is_non_premium_indicator(decoded) - if not self._premium_signal_sent and is_premium_error: - self._premium_signal_sent = True - logger.warning("=" * 80) - logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)") - logger.warning("=" * 80) - logger.warning(f"Matched pattern: '{matched_pattern}'") - logger.warning(f"Triggering line: '{decoded.strip()}'") - logger.warning("AUTHENTICATION DIAGNOSTICS:") - logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}") - if self.api_key: - logger.warning(f" Auth value length: {len(self.api_key)} chars") - if len(self.api_key) >= 8: - logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}") - auth_method = self.auth_service.get_auth_method() if self.auth_service else None - logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}") - if auth_method == 'oauth' and self.auth_service: - token_handler = self.auth_service.token_handler - token_info = token_handler.get_token_info() - logger.warning(" OAuth Token Status:") - logger.warning(f" Has token file: {token_info.get('has_token', False)}") - logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}") - if 'expires_in_minutes' in token_info: - logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes") - if 'refresh_token_age_days' in token_info: - logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days") - if token_info.get('error'): - logger.warning(f" Error: {token_info['error']}") - logger.warning("Previous engine output (last 10 lines):") - for i, buffered_line in enumerate(self._engine_output_buffer, 1): - logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}") - logger.warning("If user HAS Premium, this is a FALSE POSITIVE") - logger.warning("=" * 80) - self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") - if not self._non_premium_info_sent and 'non-premium' in decoded.lower() and 'routing' in decoded.lower(): - self._non_premium_info_sent = True - self.non_premium_detected.emit() - self._engine_output_buffer.append(decoded.strip()) - if len(self._engine_output_buffer) > self._buffer_size: - self._engine_output_buffer.pop(0) - if self.last_error is None and is_cc_content_error(decoded): - self.last_error = cc_content_missing(extract_cc_filename(decoded) or "") - if self.last_error is None and is_creation_kit_missing_error(decoded): - self.last_error = creation_kit_missing() - config_handler = ConfigHandler() - debug_mode = config_handler.get('debug_mode', False) - if self.progress_state_manager: - updated = self.progress_state_manager.process_line(decoded) - if updated: - progress_state = self.progress_state_manager.get_state() - if progress_state.active_files and debug_mode: - logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}") - self.progress_updated.emit(progress_state) - if self._handle_engine_event(decoded): - last_was_blank = False - continue + + if clf3_mode: + heartbeat_stop = threading.Event() + heartbeat_thread = threading.Thread( + target=self._run_clf3_heartbeat, + args=(self._clf3_last_activity, heartbeat_stop), + daemon=True, + ) + heartbeat_thread.start() + self._run_clf3_stdout_loop(self._clf3_last_activity) + heartbeat_stop.set() + heartbeat_thread.join(timeout=2.0) + else: + ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]') + buffer = b'' + last_was_blank = False + while True: + if self.cancelled: + self.cancel() + break + char = self.process_manager.read_stdout_char() + if not char: + break + buffer += char + while b'\n' in buffer or b'\r' in buffer: + if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True): + line, buffer = buffer.split(b'\r', 1) + line = ansi_escape.sub(b'', line) + decoded = line.decode('utf-8', errors='replace') + config_handler = ConfigHandler() + debug_mode = config_handler.get('debug_mode', False) + from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator + is_premium_error, matched_pattern = is_non_premium_indicator(decoded) + if not self._premium_signal_sent and is_premium_error: + self._premium_signal_sent = True + logger.warning("=" * 80) + logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)") + logger.warning("=" * 80) + logger.warning(f"Matched pattern: '{matched_pattern}'") + logger.warning(f"Triggering line: '{decoded.strip()}'") + logger.warning("AUTHENTICATION DIAGNOSTICS:") + logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}") + if self.api_key: + logger.warning(f" Auth value length: {len(self.api_key)} chars") + auth_method = self.auth_service.get_auth_method() if self.auth_service else None + logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}") + if auth_method == 'oauth' and self.auth_service: + token_handler = self.auth_service.token_handler + token_info = token_handler.get_token_info() + logger.warning(" OAuth Token Status:") + logger.warning(f" Has token file: {token_info.get('has_token', False)}") + logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}") + if 'expires_in_minutes' in token_info: + logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes") + if 'refresh_token_age_days' in token_info: + logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days") + if token_info.get('error'): + logger.warning(f" Error: {token_info['error']}") + logger.warning("Previous engine output (last 10 lines):") + for i, buffered_line in enumerate(self._engine_output_buffer, 1): + logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}") + logger.warning("If user HAS Premium, this is a FALSE POSITIVE") + logger.warning("=" * 80) + self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") + self._engine_output_buffer.append(decoded.strip()) + if len(self._engine_output_buffer) > self._buffer_size: + self._engine_output_buffer.pop(0) + if self.last_error is None and is_cc_content_error(decoded): + self.last_error = cc_content_missing(extract_cc_filename(decoded) or "") + if self.last_error is None and is_creation_kit_missing_error(decoded): + self.last_error = creation_kit_missing(self.game_type) + if self.progress_state_manager: + updated = self.progress_state_manager.process_line(decoded) + if updated: + progress_state = self.progress_state_manager.get_state() + if progress_state.active_files and debug_mode: + logger.debug(f"Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}") + self.progress_updated.emit(progress_state) + if '[FILE_PROGRESS]' in decoded: + self._install_progress_started = True + parts = decoded.split('[FILE_PROGRESS]', 1) + if parts[0].strip(): + self.progress_received.emit(parts[0].rstrip()) + else: + self.progress_received.emit(decoded + '\r') + elif b'\n' in buffer: + line, buffer = buffer.split(b'\n', 1) + line = ansi_escape.sub(b'', line) + decoded = line.decode('utf-8', errors='replace') + from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator + is_premium_error, matched_pattern = (False, None) if decoded.strip().startswith('{') else is_non_premium_indicator(decoded) + if not self._premium_signal_sent and is_premium_error: + self._premium_signal_sent = True + logger.warning("=" * 80) + logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)") + logger.warning("=" * 80) + logger.warning(f"Matched pattern: '{matched_pattern}'") + logger.warning(f"Triggering line: '{decoded.strip()}'") + logger.warning("AUTHENTICATION DIAGNOSTICS:") + logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}") + if self.api_key: + logger.warning(f" Auth value length: {len(self.api_key)} chars") + auth_method = self.auth_service.get_auth_method() if self.auth_service else None + logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}") + if auth_method == 'oauth' and self.auth_service: + token_handler = self.auth_service.token_handler + token_info = token_handler.get_token_info() + logger.warning(" OAuth Token Status:") + logger.warning(f" Has token file: {token_info.get('has_token', False)}") + logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}") + if 'expires_in_minutes' in token_info: + logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes") + if 'refresh_token_age_days' in token_info: + logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days") + if token_info.get('error'): + logger.warning(f" Error: {token_info['error']}") + logger.warning("Previous engine output (last 10 lines):") + for i, buffered_line in enumerate(self._engine_output_buffer, 1): + logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}") + logger.warning("If user HAS Premium, this is a FALSE POSITIVE") + logger.warning("=" * 80) + self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") + if not self._non_premium_info_sent and 'non-premium' in decoded.lower() and 'routing' in decoded.lower(): + self._non_premium_info_sent = True + self.non_premium_detected.emit() + self._engine_output_buffer.append(decoded.strip()) + if len(self._engine_output_buffer) > self._buffer_size: + self._engine_output_buffer.pop(0) + if self.last_error is None and is_cc_content_error(decoded): + self.last_error = cc_content_missing(extract_cc_filename(decoded) or "") + if self.last_error is None and is_creation_kit_missing_error(decoded): + self.last_error = creation_kit_missing(self.game_type) + config_handler = ConfigHandler() + debug_mode = config_handler.get('debug_mode', False) + if self.progress_state_manager: + updated = self.progress_state_manager.process_line(decoded) + if updated: + progress_state = self.progress_state_manager.get_state() + if progress_state.active_files and debug_mode: + logger.debug(f"Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}") + self.progress_updated.emit(progress_state) + if self._handle_engine_event(decoded): + last_was_blank = False + continue + self._remember_stdout_line(decoded) + if '[FILE_PROGRESS]' in decoded: + self._install_progress_started = True + parts = decoded.split('[FILE_PROGRESS]', 1) + if parts[0].strip(): + self.output_received.emit(parts[0].rstrip()) + last_was_blank = False + continue + if decoded.strip() == '': + if not last_was_blank: + self.output_received.emit('\n') + last_was_blank = True + else: + self.output_received.emit(decoded + '\n') + last_was_blank = False + if buffer: + line = ansi_escape.sub(b'', buffer) + decoded = line.decode('utf-8', errors='replace') + if '[FILE_PROGRESS]' in decoded: + parts = decoded.split('[FILE_PROGRESS]', 1) + if parts[0].strip(): + self.output_received.emit(parts[0].rstrip()) + else: self._remember_stdout_line(decoded) - if '[FILE_PROGRESS]' in decoded: - self._install_progress_started = True - parts = decoded.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - self.output_received.emit(parts[0].rstrip()) - last_was_blank = False - continue - if decoded.strip() == '': - if not last_was_blank: - self.output_received.emit('\n') - last_was_blank = True - else: - self.output_received.emit(decoded + '\n') - last_was_blank = False - if buffer: - line = ansi_escape.sub(b'', buffer) - decoded = line.decode('utf-8', errors='replace') - if '[FILE_PROGRESS]' in decoded: - parts = decoded.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - self.output_received.emit(parts[0].rstrip()) - else: - self._remember_stdout_line(decoded) - self.output_received.emit(decoded) + self.output_received.emit(decoded) stderr_thread.join(timeout=5) returncode = self.process_manager.wait() - if writeback_path and self.auth_service: + if writeback_path and self.auth_service and not clf3_mode: self.auth_service.apply_token_writeback(writeback_path) if self.process_manager.proc and self.process_manager.proc.stdout: try: @@ -479,7 +700,7 @@ class InstallerThread(QThread): if remaining: decoded_remaining = remaining.decode('utf-8', errors='replace') if decoded_remaining.strip(): - logger.debug(f"DEBUG: Remaining output after process exit: {decoded_remaining[:500]}") + logger.debug(f"Remaining output after process exit: {decoded_remaining[:500]}") if '[FILE_PROGRESS]' in decoded_remaining: parts = decoded_remaining.split('[FILE_PROGRESS]', 1) if parts[0].strip(): @@ -487,7 +708,7 @@ class InstallerThread(QThread): else: self.output_received.emit(decoded_remaining) except Exception as e: - logger.debug(f"DEBUG: Error reading remaining output: {e}") + logger.debug(f"Error reading remaining output: {e}") if returncode != 0 and not self.cancelled and self.last_error is None: stderr_tail = self._raw_stderr_lines[-10:] if self._raw_stderr_lines else [] stdout_tail = self._raw_stdout_lines[-10:] if self._raw_stdout_lines else [] diff --git a/jackify/frontends/gui/screens/install_modlist_output_mixin.py b/jackify/frontends/gui/screens/install_modlist_output_mixin.py index 5bd5483..892a209 100644 --- a/jackify/frontends/gui/screens/install_modlist_output_mixin.py +++ b/jackify/frontends/gui/screens/install_modlist_output_mixin.py @@ -35,24 +35,45 @@ class InstallModlistOutputMixin: if not self._token_error_notified: self._token_error_notified = True from jackify.frontends.gui.services.message_service import MessageService - MessageService.critical( - self, - "Authentication Error", - ( - "Nexus Mods authentication has failed. This may be due to:\n\n" - "• OAuth token expired and refresh failed\n" - "• Nexus Premium required for this modlist\n" - "• Network connectivity issues\n\n" - "Please check the console output (Show Details) for more information.\n" - "You may need to re-authorize in Settings." - ), - safety_level="high" - ) - guidance = ( - "\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n" - "[Jackify] This may cause downloads to stop. Check the error message above.\n" - "[Jackify] If OAuth token expired, go to Settings and re-authorize.\n" - ) + engine_id = getattr(self, '_active_session_engine_id', None) + if engine_id == 'clf3': + MessageService.critical( + self, + "CLF3 Authentication Error", + ( + "CLF3 could not authenticate with Nexus Mods.\n\n" + "The CLF3 binary stores its own Nexus API key, which may have " + "expired or been revoked. Your Jackify OAuth is unaffected.\n\n" + "To fix: generate an API key at nexus.mods.com (account page), " + "then run in a terminal:\n\n" + " clf3 set-api-key YOUR_KEY\n\n" + "OAuth support for CLF3 will be automatic after the next CLF3 release." + ), + safety_level="high" + ) + guidance = ( + "\n[Jackify] CLF3 auth failed. CLF3 uses its own saved Nexus API key " + "(not your Jackify OAuth). Run: clf3 set-api-key YOUR_KEY\n" + ) + else: + MessageService.critical( + self, + "Authentication Error", + ( + "Nexus Mods authentication has failed. This may be due to:\n\n" + "• OAuth token expired and refresh failed\n" + "• Nexus Premium required for this modlist\n" + "• Network connectivity issues\n\n" + "Please check the console output (Show Details) for more information.\n" + "You may need to re-authorize in Settings." + ), + safety_level="high" + ) + guidance = ( + "\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n" + "[Jackify] This may cause downloads to stop. Check the error message above.\n" + "[Jackify] If OAuth token expired, go to Settings and re-authorize.\n" + ) self._safe_append_text(guidance) if not self.show_details_checkbox.isChecked(): self.show_details_checkbox.setChecked(True) @@ -219,12 +240,12 @@ class InstallModlistOutputMixin: except RuntimeError as e: if "already deleted" in str(e): if getattr(self, 'debug', False): - logger.debug(f"DEBUG: Ignoring widget deletion error: {e}") + logger.debug(f"Ignoring widget deletion error: {e}") return raise except Exception as e: if getattr(self, 'debug', False): - logger.debug(f"DEBUG: Error updating file progress list: {e}") + logger.debug(f"Error updating file progress list: {e}") import logging logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) else: diff --git a/jackify/frontends/gui/screens/install_modlist_postinstall.py b/jackify/frontends/gui/screens/install_modlist_postinstall.py index 10dd293..365c6ed 100644 --- a/jackify/frontends/gui/screens/install_modlist_postinstall.py +++ b/jackify/frontends/gui/screens/install_modlist_postinstall.py @@ -1,4 +1,5 @@ """Post-install UI feedback management for InstallModlistScreen (Mixin).""" +import logging import re import time from typing import Optional @@ -7,6 +8,8 @@ from PySide6.QtCore import QTimer from jackify.shared.progress_models import InstallationProgress, InstallationPhase, FileProgress, OperationType +logger = logging.getLogger(__name__) + class PostInstallFeedbackMixin: """Mixin providing post-install progress tracking and UI feedback for InstallModlistScreen.""" @@ -180,6 +183,21 @@ class PostInstallFeedbackMixin: "bsa decompression:", ], }, + { + 'id': 'final_config', + 'label': "Performing final tasks", + 'keywords': [ + "digicert", + "certificate", + "windows version", + "windows 11", + "mscoree", + "tool compat", + "nemesis setup", + "applying tool", + "symlink", + ], + }, { 'id': 'config_finalize', 'label': "Finalising Jackify configuration", @@ -200,15 +218,54 @@ class PostInstallFeedbackMixin: self._post_install_last_label = "Preparing Steam integration" total = max(1, self._post_install_total_steps) self._update_post_install_ui(self._post_install_last_label, 0, total) + self.cancel_btn.setVisible(False) + self.cancel_install_btn.setVisible(True) def _handle_post_install_progress(self, message: str): """Translate backend progress messages into collapsed-mode feedback.""" if not self._post_install_active or not message: + if not self._post_install_active and message: + logger.debug("[PULSE] _handle_post_install_progress skipped - post_install_active=False, msg=%r", message[:60]) return text = message.strip() if not text: return + + if any(kw in text.lower() for kw in ['wine', 'vcrun', 'dotnet', 'winetricks', 'component']): + logger.debug("[PULSE] progress msg (wine-related): %r, step=%s, timer_active=%s", + text[:80], getattr(self, '_post_install_current_step', 'N/A'), + bool(getattr(self, '_component_install_timer', None) and + getattr(self._component_install_timer, 'isActive', lambda: False)())) + + if text.startswith("[NATIVE_DL] "): + parts = text.split(None, 3) + if len(parts) == 4: + _, component, pct_str, speed_str = parts + try: + if not hasattr(self, '_native_component_progress'): + self._native_component_progress = {} + self._native_component_progress[component] = (float(pct_str), float(speed_str)) + except ValueError: + pass + return + if text.startswith("[NATIVE_INSTALL] "): + component = text[17:].strip() + if hasattr(self, '_native_component_progress'): + self._native_component_progress.pop(component, None) + done = getattr(self, '_native_done_components', 0) + self._current_native_component = component + self._native_done_components = done + 1 + if hasattr(self, '_component_install_list') and self._component_install_list: + total = len(self._component_install_list) + remaining = max(0, total - (done + 1)) + suffix = f" ({remaining} remaining)" if remaining > 0 else "" + self.file_progress_list.update_files( + [FileProgress(filename=f"Wine component: {component}{suffix}", operation=OperationType.UNKNOWN, percent=0.0)], + current_phase=None, + ) + return + normalized = text.lower() total = max(1, self._post_install_total_steps) matched = False @@ -239,15 +296,19 @@ class PostInstallFeedbackMixin: # Must remove summary widget so pulser items display immediately # (otherwise the 0.5s hold blocks update_files from adding items). if step['id'] == 'wine_components': + logger.debug("[PULSE] wine_components step matched, msg=%r", text[:80]) self.file_progress_list.clear_summary() self.progress_indicator.set_status( "Installing Wine components...", int((self._post_install_current_step / total) * 100) ) - if not hasattr(self, '_component_install_timer') or not self._component_install_timer: + timer_active = hasattr(self, '_component_install_timer') and self._component_install_timer and self._component_install_timer.isActive() + logger.debug("[PULSE] timer_active=%s", timer_active) + if not timer_active: self._start_component_install_pulse() # Always check for component list updates (may come in later messages) comp_list = self._parse_wine_components_message(text) + logger.debug("[PULSE] comp_list=%s", comp_list) if comp_list: self._start_component_install_pulse_with_components(comp_list) break @@ -364,16 +425,11 @@ class PostInstallFeedbackMixin: def _update_post_install_ui(self, label: str, step: int, total: int, detail: Optional[str] = None): """Update progress indicator + activity summary for post-install steps.""" - # Use the label as the primary display, but include step info in Activity window display_label = label if detail: - # Remove timestamp prefix from detail messages clean_detail = self._strip_timestamp_prefix(detail.strip()) if clean_detail: - # Filter out winetricks/protontricks internal messages (perl, wine paths, etc.) - # These are implementation details, not user-facing status if any(keyword in clean_detail.lower() for keyword in ['perl:', 'wine:', '/usr/bin/', 'winetricks:', 'protontricks:']): - # Use original label, ignore internal tool messages pass elif clean_detail.lower().startswith(label.lower()): display_label = clean_detail @@ -383,23 +439,24 @@ class PostInstallFeedbackMixin: step_clamped = max(0, min(step, total)) overall_percent = (step_clamped / total) * 100.0 - # CRITICAL: Ensure both displays use the SAME step counter - # Progress banner uses phase_step/phase_max_steps from progress_state progress_state = InstallationProgress( phase=InstallationPhase.FINALIZE, - phase_name=display_label, # This will show in progress banner - phase_step=step_clamped, # This creates [step/total] in display_text + phase_name=display_label, + phase_step=step_clamped, phase_max_steps=total, overall_percent=overall_percent ) self.progress_indicator.update_progress(progress_state) - # Activity window uses summary_info with the SAME step counter + # When the component pulse timer is active, it owns the Activity window. + # Writing a summary widget here would block the heartbeat's file items via the 0.5s hold. + if getattr(self, '_component_install_timer', None) and self._component_install_timer.isActive(): + return + summary_info = { - 'current_step': step_clamped, # Must match phase_step above - 'max_steps': total, # Must match phase_max_steps above + 'current_step': step_clamped, + 'max_steps': total, } - # Use the same label for consistency self.file_progress_list.update_files([], current_phase=display_label, summary_info=summary_info) def _end_post_install_feedback(self, success: bool): @@ -414,6 +471,8 @@ class PostInstallFeedbackMixin: self._update_post_install_ui(label, final_step, total) self._post_install_active = False self._post_install_last_label = label + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) def _parse_wine_components_message(self, text: str): """Extract list of wine component names from backend status message, or None.""" @@ -429,40 +488,60 @@ class PostInstallFeedbackMixin: def _start_component_install_pulse(self): """Start pulsing Activity item for Wine component installation.""" + logger.debug("[PULSE] _start_component_install_pulse called, post_install_active=%s", getattr(self, '_post_install_active', 'N/A')) self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0) if not getattr(self, '_component_install_timer', None): self._component_install_timer = QTimer(self) self._component_install_timer.timeout.connect(self._component_install_heartbeat) self._component_install_timer.start(100) self._component_install_start_time = time.time() + logger.debug("[PULSE] component install timer started") def _start_component_install_pulse_with_components(self, components: list): - """Replace single item with one Activity entry per component, each with pulsing progress.""" + """Show queued count; heartbeat switches to per-component display as each starts.""" + logger.debug("[PULSE] _start_component_install_pulse_with_components called, components=%s", components) self._component_install_list = components - progresses = [ - FileProgress( - filename=f"Wine component: {comp}", - operation=OperationType.UNKNOWN, - percent=0.0, - ) - for comp in components - ] - self.file_progress_list.update_files(progresses, current_phase=None) + self._native_total_components = len(components) + self._native_done_components = 0 + self._current_native_component = None + self.file_progress_list.update_or_add_item( + "__wine_components__", + f"Wine components: {len(components)} queued", + 0.0, + ) def _component_install_heartbeat(self): - """Heartbeat to keep component install item(s) pulsing.""" + """Heartbeat to keep component install item pulsing.""" if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time: + logger.debug("[PULSE] heartbeat fired but no start_time, skipping") return + current = getattr(self, '_current_native_component', None) if hasattr(self, '_component_install_list') and self._component_install_list: - progresses = [ - FileProgress( - filename=f"Wine component: {comp}", - operation=OperationType.UNKNOWN, - percent=0.0, + total = len(self._component_install_list) + done = getattr(self, '_native_done_components', 0) + dl_state = getattr(self, '_native_component_progress', {}) + if current: + if current in dl_state: + pct, speed = dl_state[current] + label = f"Wine component: {current} | {pct:.0f}% ({speed:.1f} MB/s)" + op = OperationType.DOWNLOAD + pct_val = pct + else: + remaining = max(0, total - done) + suffix = f" ({remaining} remaining)" if remaining > 0 else "" + label = f"Wine component: {current}{suffix}" + op = OperationType.UNKNOWN + pct_val = 0.0 + self.file_progress_list.update_files( + [FileProgress(filename=label, operation=op, percent=pct_val)], + current_phase=None, + ) + else: + self.file_progress_list.update_or_add_item( + "__wine_components__", + f"Wine components: {total} queued", + 0.0, ) - for comp in self._component_install_list - ] - self.file_progress_list.update_files(progresses, current_phase=None) else: self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0) @@ -473,6 +552,8 @@ class PostInstallFeedbackMixin: self._component_install_timer = None if hasattr(self, '_component_install_list'): del self._component_install_list + if hasattr(self, '_native_component_progress'): + del self._native_component_progress def _start_bsa_decompress_pulse(self): """Keep the Activity window alive during long BSA decompression runs.""" diff --git a/jackify/frontends/gui/screens/install_modlist_progress.py b/jackify/frontends/gui/screens/install_modlist_progress.py index afc39d7..65e591c 100644 --- a/jackify/frontends/gui/screens/install_modlist_progress.py +++ b/jackify/frontends/gui/screens/install_modlist_progress.py @@ -1,5 +1,5 @@ """Progress and installation event handlers for InstallModlistScreen (Mixin).""" -from PySide6.QtCore import QProcess +from PySide6.QtCore import QProcess, QTimer from PySide6.QtWidgets import QMessageBox from PySide6.QtGui import QTextCursor from jackify.frontends.gui.services.message_service import MessageService @@ -187,7 +187,11 @@ class ProgressHandlersMixin: ) is_extraction_phase = ( progress_state.phase == InstallationPhase.EXTRACT or - (progress_state.phase_name and 'extract' in progress_state.phase_name.lower()) + (progress_state.phase_name and ( + 'extract' in progress_state.phase_name.lower() + or 'queu' in progress_state.phase_name.lower() + or 'decompress' in progress_state.phase_name.lower() + )) ) # Detect BSA building phase - check multiple indicators @@ -228,23 +232,29 @@ class ProgressHandlersMixin: self._bsa_hold_deadline = now_mono if is_installation_phase: - # During installation, we may have BSA building AND file installation happening - # Show both: install summary + any active BSA files - # Render loop handles smooth updates - just set target state - + self._stop_clf3_decompress_pulse() current_step = progress_state.phase_step + phase_lower = (progress_state.phase_name or "").lower() + + if "bsa" in phase_lower: + step_label = f"Building BSA: {current_step}/{progress_state.phase_max_steps}" + elif "dds" in phase_lower or "texture" in phase_lower: + step_label = f"Converting Textures: {current_step}/{progress_state.phase_max_steps}" + elif "extract" in phase_lower or "queu" in phase_lower: + step_label = f"Queuing Archives: {current_step}/{progress_state.phase_max_steps}" + else: + step_label = f"Installing Files: {current_step}/{progress_state.phase_max_steps}" display_items = [] - # Line 1: Always show "Installing Files: X/Y" at the top (no progress bar, no size) if current_step > 0 or progress_state.phase_max_steps > 0: install_line = FileProgress( - filename=f"Installing Files: {current_step}/{progress_state.phase_max_steps}", + filename=step_label, operation=OperationType.INSTALL, percent=0.0, speed=-1.0 ) - install_line._no_progress_bar = True # Flag to hide progress bar + install_line._no_progress_bar = True display_items.append(install_line) # Lines 2+: Show converting textures and BSA files @@ -293,25 +303,31 @@ class ProgressHandlersMixin: # Update target state (render loop handles smooth display) # Explicitly pass None for summary_info to clear any stale summary data if display_items: - self.file_progress_list.update_files(display_items, current_phase="Installing", summary_info=None) + self.file_progress_list.update_files(display_items, current_phase=phase_label or "Installing", summary_info=None) return elif is_extraction_phase: - # Show summary info for Extracting phase (step count) - # Render loop handles smooth updates - just set target state - # Explicitly pass empty list for file_progresses to clear any stale file list - current_step = progress_state.phase_step - summary_info = { - 'current_step': current_step, - 'max_steps': progress_state.phase_max_steps, - } - phase_display_name = phase_label or "Extracting" - self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info) + phase_lower = (progress_state.phase_name or "").lower() + if 'decompress' in phase_lower: + label = progress_state.message or "Decompressing archives..." + self._clf3_decompress_label = label + if not getattr(self, '_clf3_decompress_timer', None): + self._start_clf3_decompress_pulse(label) + else: + self._stop_clf3_decompress_pulse() + current_step = progress_state.phase_step + summary_info = { + 'current_step': current_step, + 'max_steps': progress_state.phase_max_steps, + } + phase_display_name = phase_label or "Queuing Archives" + self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info) return elif progress_state.active_files: + self._stop_clf3_decompress_pulse() if self.debug: - logger.debug(f"DEBUG: Updating file progress list with {len(progress_state.active_files)} files") + logger.debug(f"Updating file progress list with {len(progress_state.active_files)} files") for fp in progress_state.active_files: - logger.debug(f"DEBUG: - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})") + logger.debug(f" - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})") # Pass phase label to update header (e.g., "[Activity - Downloading]") # Explicitly clear summary_info when showing file list try: @@ -320,33 +336,64 @@ class ProgressHandlersMixin: # Widget was deleted - ignore to prevent coredump if "already deleted" in str(e): if self.debug: - logger.debug(f"DEBUG: Ignoring widget deletion error: {e}") + logger.debug(f"Ignoring widget deletion error: {e}") return raise except Exception as e: # Catch any other exceptions to prevent coredump if self.debug: - logger.debug(f"DEBUG: Error updating file progress list: {e}") + logger.debug(f"Error updating file progress list: {e}") import logging logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) else: - # Show empty state so widget stays visible even when no files are active + self._stop_clf3_decompress_pulse() + # When there are no active files but phase progress counters are set (CLF3 streaming + # pipeline: extraction runs inside the Downloading phase with no per-file events), + # show a summary widget so the Activity tab is not blank. + summary_info = None + if progress_state.phase_step > 0 or progress_state.phase_max_steps > 0: + summary_info = { + 'current_step': progress_state.phase_step, + 'max_steps': progress_state.phase_max_steps, + } try: - self.file_progress_list.update_files([], current_phase=phase_label) + self.file_progress_list.update_files([], current_phase=phase_label, summary_info=summary_info) except RuntimeError as e: - # Widget was deleted - ignore to prevent coredump if "already deleted" in str(e): return raise except Exception as e: - # Catch any other exceptions to prevent coredump - import logging - logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) + logger.error(f"Error updating file progress list: {e}", exc_info=True) + + def _start_clf3_decompress_pulse(self, label: str = "Decompressing archives..."): + self._clf3_decompress_label = label + if not getattr(self, '_clf3_decompress_timer', None): + self._clf3_decompress_timer = QTimer(self) + self._clf3_decompress_timer.timeout.connect(self._clf3_decompress_heartbeat) + self._clf3_decompress_timer.start(250) + + def _clf3_decompress_heartbeat(self): + label = getattr(self, '_clf3_decompress_label', "Decompressing archives...") + self.file_progress_list.update_or_add_item("__clf3_decompress__", label, 0.0) + + def _stop_clf3_decompress_pulse(self): + timer = getattr(self, '_clf3_decompress_timer', None) + if timer: + timer.stop() + self._clf3_decompress_timer = None + self._clf3_decompress_label = None def on_installation_finished(self, success, message): """Handle installation completion""" - logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}") - # R&D: Clear all progress displays when installation completes + self._stop_clf3_decompress_pulse() + logger.debug(f"on_installation_finished called with success={success}, message={message}") + # installation_finished is emitted from inside run() via a queued connection, + # so run() may still be executing its finally block when this slot fires. + # Destroying the thread object while run() is still on the stack causes + # "QThread: Destroyed while thread is still running" / SIGABRT. + thread = getattr(self, 'install_thread', None) + if thread and thread.isRunning(): + thread.wait(3000) self.progress_state_manager.reset() # Clear file list but keep CPU tracking running for configuration phase self.file_progress_list.list_widget.clear() @@ -382,6 +429,13 @@ class ProgressHandlersMixin: except Exception as _meta_err: logger.debug(f"Modlist meta write skipped: {_meta_err}") + try: + from jackify.backend.utils.clf3_postinstall import inject_mo2_download_dir + if thread and getattr(thread, 'install_dir', None) and getattr(thread, 'downloads_dir', None): + inject_mo2_download_dir(thread.install_dir, thread.downloads_dir) + except Exception as _ini_err: + logger.debug(f"MO2 INI download_directory injection skipped: {_ini_err}") + logger.info(f"Installation succeeded: {message}") if self.show_details_checkbox.isChecked(): self._safe_append_text(f"\nSuccess: {message}") @@ -418,12 +472,12 @@ class ProgressHandlersMixin: self.process_finished(1, QProcess.CrashExit) # Simulate error def process_finished(self, exit_code, exit_status): - logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}") + logger.debug(f"process_finished called with exit_code={exit_code}, exit_status={exit_status}") # Reset button states self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) - logger.debug("DEBUG: Button states reset in process_finished") + logger.debug("Button states reset in process_finished") # Stop manual download manager if it is still running (e.g. install failed mid-phase) if getattr(self, '_manual_dl_manager', None) is not None: diff --git a/jackify/frontends/gui/screens/install_modlist_selection.py b/jackify/frontends/gui/screens/install_modlist_selection.py index cf5423d..e3b266f 100644 --- a/jackify/frontends/gui/screens/install_modlist_selection.py +++ b/jackify/frontends/gui/screens/install_modlist_selection.py @@ -153,13 +153,13 @@ class ModlistSelectionMixin: if hasattr(self, 'current_game_type'): game_filter = game_type_to_human_friendly.get(self.current_game_type) - dlg = ModlistGalleryDialog(game_filter=game_filter, parent=self) + self._gallery_dlg = ModlistGalleryDialog(game_filter=game_filter, parent=self) if cursor_overridden: QApplication.restoreOverrideCursor() cursor_overridden = False - if dlg.exec() == QDialog.Accepted and dlg.selected_metadata: - metadata = dlg.selected_metadata + if self._gallery_dlg.exec() == QDialog.Accepted and self._gallery_dlg.selected_metadata: + metadata = self._gallery_dlg.selected_metadata self.modlist_btn.setText(metadata.title) self.selected_modlist_info = { 'machine_url': metadata.namespacedName, @@ -170,6 +170,7 @@ class ModlistSelectionMixin: 'nsfw': metadata.nsfw, 'force_down': metadata.forceDown, 'readme_url': metadata.links.readme if metadata.links else None, + 'download_url': metadata.links.download if metadata.links else None, } self.modlist_name_edit.setText(metadata.title) diff --git a/jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py b/jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py index b1b3455..aa0904e 100644 --- a/jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py +++ b/jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py @@ -1,9 +1,14 @@ """Steam shortcut conflict handling for InstallModlistScreen (Mixin).""" +import logging import os +from PySide6.QtCore import QThread, Signal + from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog from jackify.frontends.gui.services.message_service import MessageService +logger = logging.getLogger(__name__) + class InstallModlistShortcutDialogMixin: """Mixin providing shortcut conflict dialog and retry-with-new-name for InstallModlistScreen.""" @@ -62,7 +67,7 @@ class InstallModlistShortcutDialogMixin: self._restore_controls_after_shortcut_dialog_abort() return self._safe_append_text(f"Reusing existing Steam shortcut '{existing_name}'.") - self.continue_configuration_after_automated_prefix(int(existing_appid), modlist_name, install_dir, None) + self._reuse_shortcut_with_prefix_check(int(existing_appid), modlist_name, install_dir) return if action == "new": @@ -79,6 +84,54 @@ class InstallModlistShortcutDialogMixin: self._safe_append_text("Shortcut creation cancelled by user") self._restore_controls_after_shortcut_dialog_abort() + def _reuse_shortcut_with_prefix_check(self, appid: int, modlist_name: str, install_dir: str) -> None: + """Continue configuration after conflict resolution, creating the prefix if it is missing.""" + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + svc = AutomatedPrefixService() + if svc.get_prefix_path(appid): + self.continue_configuration_after_automated_prefix(appid, modlist_name, install_dir, None) + return + + logger.info("Proton prefix missing for AppID %s; creating before configuration", appid) + self._safe_append_text("[00:00:00] Proton prefix not found; creating prefix...") + + class _PrefixCreateThread(QThread): + finished = Signal(bool) + + def __init__(self, appid): + super().__init__() + self._appid = appid + + def run(self): + try: + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + ok = AutomatedPrefixService().create_prefix_with_proton_wrapper(self._appid) + self.finished.emit(ok) + except Exception as exc: + logger.error("Prefix creation thread failed: %s", exc) + self.finished.emit(False) + + _thread = _PrefixCreateThread(appid) + + def _on_done(success): + _thread.deleteLater() + if not success: + logger.error("Failed to create Proton prefix for AppID %s", appid) + MessageService.warning( + self, + "Prefix Creation Failed", + "Jackify could not create the Proton prefix.\n\n" + "Try launching the modlist from Steam once to initialise it, then run Configure.", + ) + self._restore_controls_after_shortcut_dialog_abort() + return + self._safe_append_text("[00:00:00] Proton prefix created.") + self.continue_configuration_after_automated_prefix(appid, modlist_name, install_dir, None) + + _thread.finished.connect(_on_done) + self._prefix_create_thread = _thread + _thread.start() + def retry_automated_workflow_with_new_name(self, new_name): """Retry the automated workflow with a new shortcut name.""" self.modlist_name_edit.setText(new_name) diff --git a/jackify/frontends/gui/screens/install_modlist_ttw.py b/jackify/frontends/gui/screens/install_modlist_ttw.py index 5ae7282..921bbb5 100644 --- a/jackify/frontends/gui/screens/install_modlist_ttw.py +++ b/jackify/frontends/gui/screens/install_modlist_ttw.py @@ -33,13 +33,13 @@ class TTWIntegrationMixin: # Check 3: TTW must not already be installed if self._detect_existing_ttw(install_dir): - logger.debug("DEBUG: TTW already installed, skipping prompt") + logger.debug("TTW already installed, skipping prompt") return False return True except Exception as e: - logger.debug(f"DEBUG: Error checking TTW eligibility: {e}") + logger.debug(f"Error checking TTW eligibility: {e}") return False def _detect_existing_ttw(self, install_dir: str) -> bool: @@ -73,15 +73,15 @@ class TTWIntegrationMixin: # Verify it has actual TTW content by checking for the main ESM ttw_esm = folder / "TaleOfTwoWastelands.esm" if ttw_esm.exists(): - logger.debug(f"DEBUG: Found existing TTW installation: {folder.name}") + logger.debug(f"Found existing TTW installation: {folder.name}") return True else: - logger.debug(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}") + logger.debug(f"Found TTW folder but no ESM, skipping: {folder.name}") return False except Exception as e: - logger.debug(f"DEBUG: Error detecting existing TTW: {e}") + logger.debug(f"Error detecting existing TTW: {e}") return False # Assume not installed on error def _initiate_ttw_workflow(self, modlist_name: str, install_dir: str): @@ -173,40 +173,34 @@ class TTWIntegrationMixin: vnv_automation_running = self._check_and_run_vnv_automation(self._ttw_modlist_name, self._ttw_install_dir) if vnv_automation_running: - # Store success dialog params for later (after VNV automation completes) self._pending_success_dialog_params = { 'modlist_name': modlist_name, + 'workflow_type': 'install', 'time_taken': time_str, 'game_name': game_name, - 'enb_detected': False, # TTW installs don't have ENB - 'ttw_version': ttw_version if 'ttw_version' in locals() else None + 'enb_detected': False, + 'install_dir': getattr(self, '_ttw_install_dir', '') or '', + 'game_type': 'falloutnv', + 'appid': getattr(self, '_current_appid', '') or '', } - # Keep post-install feedback active during VNV automation - # Don't show success dialog yet - will be shown in _on_vnv_complete return # No VNV automation - end post-install feedback now self._end_post_install_feedback(True) - # Clear Activity window before showing success dialog - self.file_progress_list.clear() - - # Show enhanced success dialog - from ..dialogs import SuccessDialog - success_dialog = SuccessDialog( - modlist_name=modlist_name, - workflow_type="install", - time_taken=time_str, - game_name=game_name, - parent=self + self._run_verifier_then_show_success( + install_dir=getattr(self, '_ttw_install_dir', '') or '', + game_type='falloutnv', + appid=getattr(self, '_current_appid', '') or '', + success_params={ + 'modlist_name': modlist_name, + 'workflow_type': 'install', + 'time_taken': time_str, + 'game_name': game_name, + 'enb_detected': False, + }, ) - # Add TTW installation info to dialog if possible - if 'ttw_version' in locals() and hasattr(success_dialog, 'add_info_line'): - success_dialog.add_info_line(f"TTW {ttw_version} integrated successfully") - - success_dialog.show() - except Exception as e: logger.debug(f"ERROR: Failed to show final success dialog: {e}") from jackify.frontends.gui.services.message_service import MessageService diff --git a/jackify/frontends/gui/screens/install_modlist_ui_setup.py b/jackify/frontends/gui/screens/install_modlist_ui_setup.py index ca60e7c..06d6543 100644 --- a/jackify/frontends/gui/screens/install_modlist_ui_setup.py +++ b/jackify/frontends/gui/screens/install_modlist_ui_setup.py @@ -104,7 +104,7 @@ class InstallModlistUISetupMixin: header_layout = QVBoxLayout() header_layout.setSpacing(1) # Reduce spacing between title and description # Title (no logo) - title = QLabel("Install a Modlist (Automated)") + title = QLabel("Install a Modlist") title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") title.setAlignment(Qt.AlignHCenter) title.setMaximumHeight(30) # Force compact height @@ -252,6 +252,7 @@ class InstallModlistUISetupMixin: # Update nexus status on init self._update_nexus_status() + # --- Resolution Dropdown --- resolution_label = QLabel("Resolution:") self.resolution_combo = QComboBox() @@ -293,7 +294,7 @@ class InstallModlistUISetupMixin: combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) self.resolution_combo.setCurrentIndex(resolution_index) - logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") + logger.debug(f"Loaded saved resolution: {saved_resolution} (index: {resolution_index})") elif is_steam_deck: # Set default to 1280x800 (Steam Deck) combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] @@ -304,23 +305,44 @@ class InstallModlistUISetupMixin: # Otherwise, default is 'Leave unchanged' (index 0) form_grid.addWidget(resolution_label, 5, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - # Horizontal layout for resolution dropdown and auto-restart checkbox + # Horizontal layout for resolution dropdown and right-side checkboxes resolution_and_restart_layout = QHBoxLayout() resolution_and_restart_layout.setSpacing(12) - - # Resolution dropdown (made smaller) - self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing + + self.resolution_combo.setMaximumWidth(280) resolution_and_restart_layout.addWidget(self.resolution_combo) - - # Add stretch to push checkbox to the right + resolution_and_restart_layout.addStretch() - - # Auto-accept Steam restart checkbox (right-aligned) + + right_checks_layout = QVBoxLayout() + right_checks_layout.setSpacing(4) + right_checks_layout.setContentsMargins(0, 0, 0, 0) + self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart") - self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session + self.auto_restart_checkbox.setChecked(False) self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended installation") - resolution_and_restart_layout.addWidget(self.auto_restart_checkbox) - + right_checks_layout.addWidget(self.auto_restart_checkbox) + + engine_row = QHBoxLayout() + engine_row.setSpacing(4) + engine_row.setContentsMargins(0, 0, 0, 0) + self.engine_checkbox = QCheckBox("Use Experimental Engine") + self.engine_checkbox.setToolTip("Use CLF3 (SulfurNitride) as the install engine instead of jackify-engine") + self.engine_checkbox.toggled.connect(self._on_engine_checkbox_toggled) + engine_row.addWidget(self.engine_checkbox) + engine_whats_this = QLabel('(what\'s this?)') + engine_whats_this.setOpenExternalLinks(False) + engine_whats_this.linkActivated.connect(self._open_url_safe) + engine_row.addWidget(engine_whats_this) + engine_row.addStretch() + engine_row_widget = QWidget() + engine_row_widget.setLayout(engine_row) + self._engine_row_widget = engine_row_widget + right_checks_layout.addWidget(engine_row_widget) + self._init_engine_checkbox() + + resolution_and_restart_layout.addLayout(right_checks_layout) + form_grid.addLayout(resolution_and_restart_layout, 5, 1) form_section_widget = QWidget() form_section_widget.setLayout(form_grid) @@ -514,3 +536,13 @@ class InstallModlistUISetupMixin: # Now collect all actionable controls after UI is fully built self._collect_actionable_controls() + + def _init_engine_checkbox(self) -> None: + from jackify.backend.services.tool_registry import get_active_engine_id + self._engine_row_widget.setVisible(True) + self.engine_checkbox.blockSignals(True) + self.engine_checkbox.setChecked(get_active_engine_id() == "clf3") + self.engine_checkbox.blockSignals(False) + + def _on_engine_checkbox_toggled(self, checked: bool) -> None: + pass # state is read at install time; persistent default lives in Settings diff --git a/jackify/frontends/gui/screens/install_modlist_vnv.py b/jackify/frontends/gui/screens/install_modlist_vnv.py index 9aaee40..81233d5 100644 --- a/jackify/frontends/gui/screens/install_modlist_vnv.py +++ b/jackify/frontends/gui/screens/install_modlist_vnv.py @@ -50,23 +50,15 @@ class VNVAutomationMixin: if hasattr(self, '_pending_success_dialog_params'): params = self._pending_success_dialog_params del self._pending_success_dialog_params - - self.file_progress_list.clear() - - from ..dialogs import SuccessDialog - success_dialog = SuccessDialog( - modlist_name=params['modlist_name'], - workflow_type="install", - time_taken=params['time_taken'], - game_name=params['game_name'], - parent=self, + self._run_verifier_then_show_success( + install_dir=params.get('install_dir', ''), + game_type=params.get('game_type', 'unknown'), + appid=params.get('appid', ''), + success_params={ + 'modlist_name': params['modlist_name'], + 'workflow_type': params.get('workflow_type', 'install'), + 'time_taken': params['time_taken'], + 'game_name': params.get('game_name'), + 'enb_detected': params.get('enb_detected', False), + }, ) - success_dialog.show() - - if params.get('enb_detected'): - try: - from ..dialogs.enb_proton_dialog import ENBProtonDialog - enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self) - enb_dialog.exec() - except Exception as e: - logger.warning("Failed to show ENB dialog: %s", e) diff --git a/jackify/frontends/gui/screens/install_modlist_workflow_execution.py b/jackify/frontends/gui/screens/install_modlist_workflow_execution.py index 63ec828..dcf6726 100644 --- a/jackify/frontends/gui/screens/install_modlist_workflow_execution.py +++ b/jackify/frontends/gui/screens/install_modlist_workflow_execution.py @@ -1,7 +1,8 @@ """Execution workflow methods for InstallModlistScreen (Mixin).""" from pathlib import Path -from PySide6.QtWidgets import QMessageBox +from PySide6.QtCore import QThread, Signal +from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QMessageBox import logging import os @@ -14,15 +15,107 @@ logger = logging.getLogger(__name__) class InstallWorkflowExecutionMixin: """Mixin containing install-run and manual-download dialog execution methods.""" + + def _session_engine_id(self) -> str: + """Return the engine to use for this install based on the install screen checkbox.""" + return "clf3" if self.engine_checkbox.isChecked() else "jackify-engine" + + def _ensure_clf3_installed(self) -> bool: + """ + If CLF3 is already installed, return True immediately. + If not, show a download dialog and install it, returning True on success. + """ + from jackify.backend.services.tool_registry import ToolRegistry + status = ToolRegistry().get_status("clf3") + if status and status.installed: + return True + + reply = QMessageBox.question( + self, + "CLF3 Not Installed", + "The experimental engine (CLF3) is not installed.\n\n" + "Download and install it now to continue?", + QMessageBox.Yes | QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return False + + return self._download_clf3_with_dialog() + + def _download_clf3_with_dialog(self) -> bool: + """Download CLF3 in a modal dialog with a pulsing progress bar. Returns True on success.""" + + class _Clf3InstallThread(QThread): + finished_signal = Signal(bool, str) + + def run(self): + try: + ok, msg = ToolRegistry().install("clf3") + self.finished_signal.emit(ok, msg) + except Exception as exc: + self.finished_signal.emit(False, str(exc)) + + from jackify.backend.services.tool_registry import ToolRegistry + from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE + + dlg = QDialog(self) + dlg.setWindowTitle("Installing CLF3") + dlg.setModal(True) + dlg.setMinimumWidth(360) + layout = QVBoxLayout(dlg) + layout.setSpacing(12) + layout.setContentsMargins(16, 16, 16, 16) + + label = QLabel("Downloading CLF3 (experimental engine)...") + label.setStyleSheet("color: #ccc; font-size: 13px;") + layout.addWidget(label) + + bar = QProgressBar() + bar.setRange(0, 0) + bar.setTextVisible(False) + bar.setFixedHeight(6) + bar.setStyleSheet(f""" + QProgressBar {{ border: none; background-color: #333; border-radius: 3px; }} + QProgressBar::chunk {{ background-color: {JACKIFY_COLOR_BLUE}; border-radius: 3px; }} + """) + layout.addWidget(bar) + + result = [False, ""] + + thread = _Clf3InstallThread() + + def on_done(ok: bool, msg: str): + result[0] = ok + result[1] = msg + dlg.accept() + + thread.finished_signal.connect(on_done) + thread.start() + dlg.exec() + thread.wait(5000) + + if not result[0]: + QMessageBox.critical( + self, + "CLF3 Install Failed", + f"Could not install CLF3:\n\n{result[1]}", + ) + return False + + label.setText("CLF3 installed.") + return True + def validate_and_start_install(self): import time self._install_workflow_start_time = time.time() - logger.debug('DEBUG: validate_and_start_install called') + + # Disable controls before processEvents to prevent double-click re-entry + self._disable_controls_during_operation() # Immediately show "Initialising" status to provide feedback self.progress_indicator.set_status("Initialising...", 0) from PySide6.QtWidgets import QApplication - QApplication.processEvents() # Force UI update + QApplication.processEvents() # Reload config to pick up any settings changes made in Settings dialog self.config_handler.reload_config() @@ -30,12 +123,18 @@ class InstallWorkflowExecutionMixin: # Check protontricks before proceeding if not self._check_protontricks(): self.progress_indicator.reset() + self._enable_controls_after_operation() return - - # Disable all controls during installation (except Cancel) - self._disable_controls_during_operation() try: + install_dir = self.install_dir_edit.text().strip() + downloads_dir = self.downloads_dir_edit.text().strip() + + if self._session_engine_id() == "clf3" and not self._ensure_clf3_installed(): + self.progress_indicator.reset() + self._enable_controls_after_operation() + return + tab_index = self.source_tabs.currentIndex() install_mode = 'online' if tab_index == 1: # .wabbajack File tab @@ -70,8 +169,22 @@ class InstallWorkflowExecutionMixin: # CRITICAL: Use machine_url, NOT button text modlist = machine_url - install_dir = self.install_dir_edit.text().strip() - downloads_dir = self.downloads_dir_edit.text().strip() + + if self._session_engine_id() == "clf3": + download_url = self.selected_modlist_info.get('download_url') + if not download_url: + self._abort_with_message( + "warning", + "Download URL Unavailable", + "Could not determine the download URL for this modlist.\n\n" + "Use the '.wabbajack File' tab to select a local file instead." + ) + return + from jackify.shared.paths import get_jackify_downloads_dir + list_id = machine_url.split('/')[-1] if '/' in machine_url else machine_url + wabbajack_local = str(get_jackify_downloads_dir() / f"{list_id}.wabbajack") + modlist = wabbajack_local + self._clf3_cdn_url = download_url # Get authentication token (OAuth or API key) with automatic refresh api_key, oauth_info = self.auth_service.get_auth_for_engine() @@ -92,8 +205,6 @@ class InstallWorkflowExecutionMixin: logger.info("Authentication Status at Install Start") logger.info(f"Method: {auth_method or 'UNKNOWN'}") logger.info(f"Token length: {len(api_key)} chars") - if len(api_key) >= 8: - logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}") if auth_method == 'oauth': token_handler = self.auth_service.token_handler @@ -169,14 +280,14 @@ class InstallWorkflowExecutionMixin: self._current_resolution = raw_resolution success = self.resolution_service.save_resolution(resolution) if success: - logger.debug(f"DEBUG: Resolution saved successfully: {resolution}") + logger.debug(f"Resolution saved successfully: {resolution}") else: - logger.debug("DEBUG: Failed to save resolution") + logger.debug("Failed to save resolution") else: # Clear saved resolution if "Leave unchanged" is selected if self.resolution_service.has_saved_resolution(): self.resolution_service.clear_saved_resolution() - logger.debug("DEBUG: Saved resolution cleared") + logger.debug("Saved resolution cleared") ensure_flatpak_steam_filesystem_access(Path(install_dir)) @@ -227,7 +338,7 @@ class InstallWorkflowExecutionMixin: if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: readme_url = self.selected_modlist_info.get('readme_url') game_name = self.selected_modlist_info.get('game', '') - logger.debug(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'") + logger.debug(f"Detected game_name from selected_modlist_info: '{game_name}'") # Map game name to game type game_mapping = { @@ -247,12 +358,12 @@ class InstallWorkflowExecutionMixin: "baldur's gate 3": 'bg3', } game_type = game_mapping.get(game_name.lower()) - logger.debug(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'") + logger.debug(f"Mapped game_name '{game_name}' to game_type: '{game_type}'") if not game_type: game_type = 'unknown' - logger.debug(f"DEBUG: Game type not found in mapping, setting to 'unknown'") + logger.debug(f"Game type not found in mapping, setting to 'unknown'") else: - logger.debug(f"DEBUG: No selected_modlist_info found") + logger.debug(f"No selected_modlist_info found") game_type = 'unknown' # Store game type and name for later use @@ -260,13 +371,13 @@ class InstallWorkflowExecutionMixin: self._current_game_name = game_name # Check if game is supported - logger.debug(f"DEBUG: Checking if game_type '{game_type}' is supported") - logger.debug(f"DEBUG: game_type='{game_type}', game_name='{game_name}'") + logger.debug(f"Checking if game_type '{game_type}' is supported") + logger.debug(f"game_type='{game_type}', game_name='{game_name}'") is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False - logger.debug(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}") + logger.debug(f"is_supported_game('{game_type}') returned: {is_supported}") if game_type and not is_supported: - logger.debug(f"DEBUG: Game '{game_type}' is not supported, showing dialog") + logger.debug(f"Game '{game_type}' is not supported, showing dialog") from ..widgets.unsupported_game_dialog import UnsupportedGameDialog dialog = UnsupportedGameDialog(self, game_name) if not dialog.show_dialog(self, game_name): @@ -366,7 +477,8 @@ class InstallWorkflowExecutionMixin: self._record_pre_update_ini_snapshot(install_real) # CRITICAL: Final safety check - ensure online modlists use machine_url - if install_mode == 'online': + # CLF3 is exempt: it uses a pre-resolved local .wabbajack path, not machine_url + if install_mode == 'online' and self._session_engine_id() != "clf3": if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: expected_machine_url = self.selected_modlist_info.get('machine_url') if expected_machine_url: @@ -393,27 +505,31 @@ class InstallWorkflowExecutionMixin: readme_url = readme_url.replace("/main/", "/blob/main/") readme_url = readme_url.replace("/master/", "/blob/master/") logger.info(f"Opening modlist readme: {readme_url}") - clean_env = {k: v for k, v in os.environ.items() if k not in ("LD_LIBRARY_PATH", "LD_PRELOAD")} - subprocess.Popen(["xdg-open", readme_url], env=clean_env) + _strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"} + clean_env = {k: v for k, v in os.environ.items() if k not in _strip} + subprocess.Popen(["xdg-open", readme_url], env=clean_env, start_new_session=True) self._safe_append_text( "Modlist readme opened in your browser. " "Check it for any manual post-install steps before launching the game." ) - logger.debug(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}') + logger.debug(f"Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}") self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info) except Exception as e: - logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}") - import traceback - logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}") - # Re-enable all controls after exception + logger.error("Unexpected error in validate_and_start_install", exc_info=True) self._enable_controls_after_operation() self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) - logger.debug(f"DEBUG: Controls re-enabled in exception handler") + from jackify.shared.paths import get_jackify_logs_dir + from ..services.message_service import MessageService + MessageService.critical( + self, + "Installation Error", + f"Could not start the installation.\n\n{e}\n\n" + f"Details were written to the Jackify log at:\n{get_jackify_logs_dir()}", + ) def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None): - logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER') # Rotate log file at start of each workflow run (keep 5 backups) from jackify.backend.handlers.logging_handler import LoggingHandler @@ -434,10 +550,15 @@ class InstallWorkflowExecutionMixin: self._downloads_dir = downloads_dir self.install_thread = InstallerThread( modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode, - progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager - auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics - oauth_info=oauth_info, # Pass OAuth state for auto-refresh + progress_state_manager=self.progress_state_manager, + auth_service=self.auth_service, + oauth_info=oauth_info, + game_type=getattr(self, '_current_game_type', None), + clf3_cdn_url=getattr(self, '_clf3_cdn_url', None), + engine_id=self._session_engine_id(), ) + self._clf3_cdn_url = None + self._active_session_engine_id = self._session_engine_id() self.install_thread.output_received.connect(self.on_installation_output) self.install_thread.progress_received.connect(self.on_installation_progress) self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update diff --git a/jackify/frontends/gui/screens/install_ttw.py b/jackify/frontends/gui/screens/install_ttw.py index f2f7fde..17d3a80 100644 --- a/jackify/frontends/gui/screens/install_ttw.py +++ b/jackify/frontends/gui/screens/install_ttw.py @@ -116,10 +116,13 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T def _open_url_safe(self, url): """Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime""" import subprocess + import os + _strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"} + clean_env = {k: v for k, v in os.environ.items() if k not in _strip} try: - subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.Popen(['xdg-open', url], env=clean_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) except Exception as e: - print(f"Warning: Could not open URL {url}: {e}") + logger.warning(f"Could not open URL {url}: {e}") def _load_saved_parent_directories(self): """No-op: do not pre-populate install/download directories from saved values.""" @@ -136,16 +139,16 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T if saved_install_parent: suggested_install_dir = os.path.join(saved_install_parent, modlist_name) self.install_dir_edit.setText(suggested_install_dir) - logger.debug(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}") + logger.debug(f"Updated install directory suggestion: {suggested_install_dir}") # Update download directory suggestion saved_download_parent = self.config_handler.get_default_download_parent_dir() if saved_download_parent: suggested_download_dir = os.path.join(saved_download_parent, "Downloads") - logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}") + logger.debug(f"Updated download directory suggestion: {suggested_download_dir}") except Exception as e: - logger.debug(f"DEBUG: Error updating directory suggestions: {e}") + logger.debug(f"Error updating directory suggestions: {e}") def _save_parent_directories(self, install_dir, downloads_dir): """Removed automatic saving - user should set defaults in settings""" @@ -341,14 +344,25 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T font-size: 13px; """) - # Park all threads first (disconnects signals), then send cooperative cancel. - self._park_all_threads() + # Send process-group kill first so the subprocess tree dies before we + # disconnect signals or wait on the thread. if hasattr(self, 'install_thread') and self.install_thread: try: self.install_thread.cancel() except Exception: pass - + + # Park threads (disconnects signals so no callbacks fire on the dying widget). + self._park_all_threads() + + # Wait up to 5 s for the thread to exit after the kill signal. + if hasattr(self, 'install_thread') and self.install_thread: + try: + self.install_thread.wait(5000) + except Exception: + pass + self.install_thread = None + # Cleanup any remaining processes self.cleanup_processes() diff --git a/jackify/frontends/gui/screens/install_ttw_integration.py b/jackify/frontends/gui/screens/install_ttw_integration.py index 6feb16e..fccf854 100644 --- a/jackify/frontends/gui/screens/install_ttw_integration.py +++ b/jackify/frontends/gui/screens/install_ttw_integration.py @@ -191,7 +191,7 @@ class TTWIntegrationMixin: self.status_banner.setText("TTW integration completed successfully!") self.status_banner.setStyleSheet(f""" QLabel {{ - background-color: #28a745; + background-color: #1a3040; color: white; font-weight: bold; padding: 8px; diff --git a/jackify/frontends/gui/screens/install_ttw_lifecycle.py b/jackify/frontends/gui/screens/install_ttw_lifecycle.py index 8a88186..5fca57f 100644 --- a/jackify/frontends/gui/screens/install_ttw_lifecycle.py +++ b/jackify/frontends/gui/screens/install_ttw_lifecycle.py @@ -51,7 +51,7 @@ class TTWLifecycleMixin: def showEvent(self, event): """Called when the widget becomes visible""" super().showEvent(event) - logger.debug(f"DEBUG: TTW showEvent - integration_mode={self._integration_mode}") + logger.debug(f"TTW showEvent - integration_mode={self._integration_mode}") # Check TTW_Linux_Installer status asynchronously (non-blocking) after screen opens from PySide6.QtCore import QTimer @@ -73,7 +73,7 @@ class TTWLifecycleMixin: is_steamdeck = True if is_steamdeck: - logger.debug("DEBUG: Steam Deck detected, keeping expanded") + logger.debug("Steam Deck detected, keeping expanded") # Force expanded state and hide checkbox if self.show_details_checkbox.isVisible(): self.show_details_checkbox.setVisible(False) @@ -84,27 +84,27 @@ class TTWLifecycleMixin: self.console.setMaximumHeight(16777215) # Remove height limit return except Exception as e: - logger.debug(f"DEBUG: Steam Deck check exception: {e}") + logger.debug(f"Steam Deck check exception: {e}") pass - logger.debug(f"DEBUG: Checkbox checked={self.show_details_checkbox.isChecked()}") + logger.debug(f"Checkbox checked={self.show_details_checkbox.isChecked()}") if self.show_details_checkbox.isChecked(): self.show_details_checkbox.blockSignals(True) self.show_details_checkbox.setChecked(False) self.show_details_checkbox.blockSignals(False) - logger.debug("DEBUG: Calling _toggle_console_visibility(Unchecked)") + logger.debug("Calling _toggle_console_visibility(Unchecked)") self._toggle_console_visibility(_Qt.Unchecked) # Force the window to compact height to eliminate bottom whitespace main_window = self.window() - logger.debug(f"DEBUG: main_window={main_window}, size={main_window.size() if main_window else None}") + logger.debug(f"main_window={main_window}, size={main_window.size() if main_window else None}") if main_window: # Save original geometry once if self._saved_geometry is None: self._saved_geometry = main_window.geometry() - logger.debug(f"DEBUG: Saved geometry: {self._saved_geometry}") + logger.debug(f"Saved geometry: {self._saved_geometry}") if self._saved_min_size is None: self._saved_min_size = main_window.minimumSize() - logger.debug(f"DEBUG: Saved min size: {self._saved_min_size}") + logger.debug(f"Saved min size: {self._saved_min_size}") # Fixed compact size - same as menu screens from PySide6.QtCore import QSize @@ -120,14 +120,14 @@ class TTWLifecycleMixin: # Notify parent to ensure compact try: self.resize_request.emit('collapse') - logger.debug("DEBUG: Emitted resize_request collapse signal") + logger.debug("Emitted resize_request collapse signal") except Exception as e: - logger.debug(f"DEBUG: Exception emitting signal: {e}") + logger.debug(f"Exception emitting signal: {e}") pass except Exception as e: - logger.debug(f"DEBUG: showEvent exception: {e}") + logger.debug(f"showEvent exception: {e}") import traceback - logger.debug(f"DEBUG: {traceback.format_exc()}") + logger.debug(f"{traceback.format_exc()}") pass def hideEvent(self, event): @@ -141,8 +141,8 @@ class TTWLifecycleMixin: # Important when console is expanded main_window.setMaximumSize(QSize(16777215, 16777215)) main_window.setMinimumSize(QSize(0, 0)) - logger.debug("DEBUG: Install TTW hideEvent - cleared window size constraints") + logger.debug("Install TTW hideEvent - cleared window size constraints") except Exception as e: - logger.debug(f"DEBUG: hideEvent exception: {e}") + logger.debug(f"hideEvent exception: {e}") pass diff --git a/jackify/frontends/gui/screens/install_ttw_requirements.py b/jackify/frontends/gui/screens/install_ttw_requirements.py index da2c6b5..e6005cf 100644 --- a/jackify/frontends/gui/screens/install_ttw_requirements.py +++ b/jackify/frontends/gui/screens/install_ttw_requirements.py @@ -1,291 +1,119 @@ """TTW installer requirements and validation for InstallTTWScreen (Mixin).""" -from PySide6.QtCore import QThread, Signal -from PySide6.QtWidgets import QMessageBox -from jackify.frontends.gui.services.message_service import MessageService -from pathlib import Path -import os -import requests -import traceback import logging +from pathlib import Path +from typing import Dict, Optional, Tuple + +from jackify.frontends.gui.services.message_service import MessageService logger = logging.getLogger(__name__) + +# Maps TTW-required game display names to Jackify game_type strings. +_TTW_GAMES = { + 'Fallout 3': 'fallout3', + 'Fallout New Vegas': 'falloutnv', +} + + +def _detect_ttw_games() -> Dict[str, Tuple[Path, str]]: + """ + Return a dict of {display_name: (path, store)} for each TTW-required game found. + Tries Steam appmanifests first, then Heroic (GOG/Epic). + """ + from jackify.backend.handlers.vanilla_game_finder import VanillaGameFinder + finder = VanillaGameFinder() + results = {} + for display_name, game_type in _TTW_GAMES.items(): + location = finder.find(game_type) + if location: + results[display_name] = location + return results + + class TTWRequirementsMixin: """Mixin providing TTW installer requirement checking and validation for InstallTTWScreen.""" - def check_requirements(self): - """Check and display requirements status""" - from jackify.backend.handlers.path_handler import PathHandler - from jackify.backend.handlers.filesystem_handler import FileSystemHandler - from jackify.backend.handlers.config_handler import ConfigHandler - from jackify.backend.models.configuration import SystemInfo - - path_handler = PathHandler() - - # Check game detection - detected_games = path_handler.find_vanilla_game_paths() - - # Fallout 3 - if 'Fallout 3' in detected_games: - self.fallout3_status.setText("Fallout 3: Detected") - self.fallout3_status.setStyleSheet("color: #3fd0ea;") - else: - self.fallout3_status.setText("Fallout 3: Not Found - Install from Steam") - self.fallout3_status.setStyleSheet("color: #f44336;") - - # Fallout New Vegas - if 'Fallout New Vegas' in detected_games: - self.fnv_status.setText("Fallout New Vegas: Detected") - self.fnv_status.setStyleSheet("color: #3fd0ea;") - else: - self.fnv_status.setText("Fallout New Vegas: Not Found - Install from Steam") - self.fnv_status.setStyleSheet("color: #f44336;") - - # Update Start button state after checking requirements - self._update_start_button_state() - - def _check_ttw_installer_status(self): - """Check TTW_Linux_Installer installation status and update UI""" - try: - from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler - from jackify.backend.handlers.filesystem_handler import FileSystemHandler - from jackify.backend.handlers.config_handler import ConfigHandler - from jackify.backend.models.configuration import SystemInfo - - # Create handler instances - filesystem_handler = FileSystemHandler() - config_handler = ConfigHandler() - system_info = SystemInfo(is_steamdeck=False) - ttw_installer_handler = TTWInstallerHandler( - steamdeck=False, - verbose=False, - filesystem_handler=filesystem_handler, - config_handler=config_handler - ) - - # Check if TTW_Linux_Installer is installed - ttw_installer_handler._check_installation() + _ttw_installer_ready: bool = False - if ttw_installer_handler.ttw_installer_installed: - # Check version against pinned/latest - update_available, installed_v, target_v = ttw_installer_handler.is_ttw_installer_update_available() - if update_available: - # Determine if this is a downgrade or upgrade - from jackify.backend.handlers.ttw_installer_handler import TTW_INSTALLER_PINNED_VERSION - if TTW_INSTALLER_PINNED_VERSION and installed_v and target_v: - # If we have a pinned version and installed is newer, it's a downgrade - try: - # Simple version comparison - if installed version string is longer/more complex, likely newer - # For now, just check if they're different and show appropriate message - if installed_v != target_v: - version_text = f"Update to v{target_v} (currently v{installed_v})" - else: - version_text = f"Update available (v{installed_v} → v{target_v})" - except Exception: - version_text = f"Update to v{target_v}" if target_v else "Update available" - else: - # Normal update (newer version available) - version_text = f"Update available (v{installed_v} → v{target_v})" if installed_v and target_v else "Update available" - self.ttw_installer_status.setText(version_text) - self.ttw_installer_status.setStyleSheet("color: #f44336;") - self.ttw_installer_btn.setText("Update now") - self.ttw_installer_btn.setEnabled(True) - self.ttw_installer_btn.setVisible(True) - else: - version_text = f"Ready (v{installed_v})" if installed_v else "Ready" - self.ttw_installer_status.setText(version_text) - self.ttw_installer_status.setStyleSheet("color: #3fd0ea;") - self.ttw_installer_btn.setText("Update now") - self.ttw_installer_btn.setEnabled(False) # Greyed out when ready - self.ttw_installer_btn.setVisible(True) + def check_requirements(self): + detected = _detect_ttw_games() + + for display_name, label_widget in ( + ('Fallout 3', self.fallout3_status), + ('Fallout New Vegas', self.fnv_status), + ): + if display_name in detected: + _, store = detected[display_name] + store_label = {'steam': 'Steam', 'gog': 'GOG', 'epic': 'Epic'}.get(store, store) + label_widget.setText(f"{display_name}: Detected ({store_label})") + label_widget.setStyleSheet("color: #3fd0ea;") else: - self.ttw_installer_status.setText("Not Found") - self.ttw_installer_status.setStyleSheet("color: #f44336;") - self.ttw_installer_btn.setText("Install now") - self.ttw_installer_btn.setEnabled(True) - self.ttw_installer_btn.setVisible(True) - + label_widget.setText(f"{display_name}: Not Found") + label_widget.setStyleSheet("color: #f44336;") + + self._update_start_button_state() + + def _check_ttw_installer_status(self): + status = None + try: + from jackify.backend.services.tool_registry import ToolRegistry + status = ToolRegistry().get_status("ttw_installer") + self._ttw_installer_ready = bool(status and status.installed) except Exception as e: - self.ttw_installer_status.setText("Check Failed") + logger.debug("TTW installer status check failed: %s", e) + self._ttw_installer_ready = False + + if self._ttw_installer_ready: + version_text = f"Ready (v{status.installed_version})" if status and status.installed_version else "Ready" + self.ttw_installer_status.setText(version_text) + self.ttw_installer_status.setStyleSheet("color: #3fd0ea;") + self.ttw_installer_btn.setVisible(False) + else: + self.ttw_installer_status.setText("Not installed - install via Tools Hub") self.ttw_installer_status.setStyleSheet("color: #f44336;") - self.ttw_installer_btn.setText("Install now") + self.ttw_installer_btn.setText("Open Tools Hub") self.ttw_installer_btn.setEnabled(True) self.ttw_installer_btn.setVisible(True) - logger.debug(f"DEBUG: TTW_Linux_Installer status check failed: {e}") + + self._update_start_button_state() def install_ttw_installer(self): - """Install or update TTW_Linux_Installer""" - # If not detected, show info dialog - try: - current_status = self.ttw_installer_status.text().strip() - except Exception: - current_status = "" - if current_status == "Not Found": - MessageService.information( - self, - "TTW_Linux_Installer Installation", - ( - "TTW_Linux_Installer is a native Linux installer for TTW and other MPI packages.

" - "Project: github.com/SulfurNitride/TTW_Linux_Installer
" - "Please star the repository and thank the developer.

" - "Jackify will now download and install the latest Linux build of TTW_Linux_Installer." - ), - safety_level="low", - ) + """Navigate to Tools Hub for TTW Linux Installer management.""" + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(10) - # Update button to show installation in progress - self.ttw_installer_btn.setText("Installing...") - self.ttw_installer_btn.setEnabled(False) + def _check_ttw_requirements(self, silent: bool = False) -> bool: + detected = _detect_ttw_games() + missing = [name for name in _TTW_GAMES if name not in detected] - self.console.append("Installing/updating TTW_Linux_Installer...") - - # Create background thread for installation - from PySide6.QtCore import QThread, Signal - - class InstallerDownloadThread(QThread): - finished = Signal(bool, str) # success, message - progress = Signal(str) # progress message - - def run(self): - try: - from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler - from jackify.backend.handlers.filesystem_handler import FileSystemHandler - from jackify.backend.handlers.config_handler import ConfigHandler - from jackify.backend.models.configuration import SystemInfo - - # Create handler instances - filesystem_handler = FileSystemHandler() - config_handler = ConfigHandler() - system_info = SystemInfo(is_steamdeck=False) - ttw_installer_handler = TTWInstallerHandler( - steamdeck=False, - verbose=False, - filesystem_handler=filesystem_handler, - config_handler=config_handler - ) - - # Install TTW_Linux_Installer (this will download and extract) - self.progress.emit("Downloading TTW_Linux_Installer...") - success, message = ttw_installer_handler.install_ttw_installer() - - if success: - install_path = ttw_installer_handler.ttw_installer_dir - self.progress.emit(f"Installation complete: {install_path}") - else: - self.progress.emit(f"Installation failed: {message}") - - self.finished.emit(success, message) - - except Exception as e: - error_msg = f"Error installing TTW_Linux_Installer: {str(e)}" - self.progress.emit(error_msg) - logger.debug(f"DEBUG: TTW_Linux_Installer installation error: {e}") - self.finished.emit(False, error_msg) - - # Create and start thread - self.installer_download_thread = InstallerDownloadThread() - self.installer_download_thread.progress.connect(self._on_installer_download_progress) - self.installer_download_thread.finished.connect(self._on_installer_download_finished) - self.installer_download_thread.start() - - # Update Activity window to show download in progress - self.file_progress_list.clear() - self.file_progress_list.update_or_add_item( - item_id="ttw_installer_download", - label="Downloading TTW_Linux_Installer...", - progress=0 - ) - - def _on_installer_download_progress(self, message): - """Handle installer download progress updates""" - self.console.append(message) - # Update Activity window based on progress message - if "Downloading" in message: - self.file_progress_list.update_or_add_item( - item_id="ttw_installer_download", - label="Downloading TTW_Linux_Installer...", - progress=0 # Indeterminate progress - ) - elif "Extracting" in message or "extracting" in message.lower(): - self.file_progress_list.update_or_add_item( - item_id="ttw_installer_download", - label="Extracting TTW_Linux_Installer...", - progress=50 - ) - elif "complete" in message.lower() or "successfully" in message.lower(): - self.file_progress_list.update_or_add_item( - item_id="ttw_installer_download", - label="TTW_Linux_Installer ready", - progress=100 - ) - - def _on_installer_download_finished(self, success, message): - """Handle installer download completion""" - if success: - self.console.append("TTW_Linux_Installer installed successfully") - # Clear Activity window after successful installation - self.file_progress_list.clear() - # Re-check status after installation (this will update button state correctly) - self._check_ttw_installer_status() - self._update_start_button_state() - else: - self.console.append(f"Installation failed: {message}") - # Clear Activity window on failure - self.file_progress_list.clear() - # Re-enable button on failure so user can retry - self.ttw_installer_btn.setText("Install now") - self.ttw_installer_btn.setEnabled(True) - - def _check_ttw_requirements(self): - """Check TTW requirements before installation""" - from jackify.backend.handlers.path_handler import PathHandler - - path_handler = PathHandler() - - # Check game detection - detected_games = path_handler.find_vanilla_game_paths() - missing_games = [] - - if 'Fallout 3' not in detected_games: - missing_games.append("Fallout 3") - if 'Fallout New Vegas' not in detected_games: - missing_games.append("Fallout New Vegas") - - if missing_games: - MessageService.warning( - self, - "Missing Required Games", - f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\nMissing: {', '.join(missing_games)}" - ) + if missing: + if not silent: + MessageService.warning( + self, + "Missing Required Games", + f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\n" + f"Not found: {', '.join(missing)}\n\n" + "Install via Steam, GOG (through Heroic), or another supported store." + ) return False - - # Check TTW_Linux_Installer using the status we already checked - status_text = self.ttw_installer_status.text() - if status_text in ("Not Found", "Check Failed"): - MessageService.warning( - self, - "TTW_Linux_Installer Required", - "TTW_Linux_Installer is required for TTW installation but is not installed.\n\nPlease install TTW_Linux_Installer using the 'Install now' button." - ) + + if not self._ttw_installer_ready: + if not silent: + MessageService.warning( + self, + "TTW Linux Installer Required", + "TTW Linux Installer is not installed.\n\nInstall it from the Tools Hub before proceeding." + ) return False - + return True - + def _update_start_button_state(self): - """Enable/disable Start button based on requirements and file selection""" - # Check if all requirements are met - requirements_met = self._check_ttw_requirements() - - # Check if .mpi file is selected + requirements_met = self._check_ttw_requirements(silent=True) mpi_file_selected = bool(self.file_edit.text().strip()) - - # Enable Start button only if both requirements are met and file is selected self.start_btn.setEnabled(requirements_met and mpi_file_selected) - - # Update button text to indicate what's missing if not requirements_met: self.start_btn.setText("Requirements Not Met") elif not mpi_file_selected: self.start_btn.setText("Select TTW .mpi File") else: self.start_btn.setText("Start Installation") - diff --git a/jackify/frontends/gui/screens/install_ttw_thread.py b/jackify/frontends/gui/screens/install_ttw_thread.py index f184b63..c3b3e09 100644 --- a/jackify/frontends/gui/screens/install_ttw_thread.py +++ b/jackify/frontends/gui/screens/install_ttw_thread.py @@ -1,5 +1,7 @@ """TTW installation worker thread.""" from PySide6.QtCore import QThread, Signal +import os +import signal import time from ..utils import strip_ansi_control_codes @@ -21,11 +23,15 @@ class TTWInstallationThread(QThread): def cancel(self): self.cancelled = True - try: - if self.proc and self.proc.poll() is None: - self.proc.terminate() - except Exception: - pass + if self.proc and self.proc.poll() is None: + try: + pgid = os.getpgid(self.proc.pid) + os.killpg(pgid, signal.SIGTERM) + except Exception: + try: + self.proc.terminate() + except Exception: + pass def process_and_buffer_line(self, raw_line): """Clean one output line and queue it for batched emit.""" @@ -62,7 +68,7 @@ class TTWInstallationThread(QThread): from pathlib import Path import tempfile - self.process_and_buffer_line("Initializing TTW installation...") + self.process_and_buffer_line("Initialising TTW installation...") self.flush_output_buffer() filesystem_handler = FileSystemHandler() diff --git a/jackify/frontends/gui/screens/install_ttw_ui.py b/jackify/frontends/gui/screens/install_ttw_ui.py index 22a30c2..6c3ab93 100644 --- a/jackify/frontends/gui/screens/install_ttw_ui.py +++ b/jackify/frontends/gui/screens/install_ttw_ui.py @@ -84,7 +84,7 @@ class TTWUIMixin: # On Steam Deck, skip window resizing - keep default Steam Deck window size if is_steamdeck: - logger.debug("DEBUG: Steam Deck detected, skipping window resize in _toggle_console_visibility") + logger.debug("Steam Deck detected, skipping window resize in _toggle_console_visibility") return # Restore main window to normal size (clear any compact constraints) @@ -137,7 +137,7 @@ class TTWUIMixin: # On Steam Deck, skip window resizing to keep maximized state if is_steamdeck: - logger.debug("DEBUG: Steam Deck detected, skipping window resize in collapse branch") + logger.debug("Steam Deck detected, skipping window resize in collapse branch") return # Use fixed compact height for consistency across all workflow screens diff --git a/jackify/frontends/gui/screens/install_ttw_workflow.py b/jackify/frontends/gui/screens/install_ttw_workflow.py index dace1cd..abf613e 100644 --- a/jackify/frontends/gui/screens/install_ttw_workflow.py +++ b/jackify/frontends/gui/screens/install_ttw_workflow.py @@ -23,10 +23,9 @@ class TTWWorkflowMixin: def validate_and_start_install(self): import time self._install_workflow_start_time = time.time() - logger.debug('DEBUG: validate_and_start_install called') self.config_handler.reload_config() - logger.debug('DEBUG: Reloaded config from disk') + logger.debug("Reloaded config from disk") if not self._check_ttw_requirements(): return @@ -87,13 +86,13 @@ class TTWWorkflowMixin: shutil.rmtree(item) else: item.unlink() - logger.debug(f"DEBUG: Deleted all contents of {install_dir}") + logger.debug(f"Deleted all contents of {install_dir}") except Exception as e: MessageService.show_error(self, install_dir_create_failed(str(install_dir), str(e))) self._enable_controls_after_operation() return except Exception as e: - logger.debug(f"DEBUG: Error checking directory contents: {e}") + logger.debug(f"Error checking directory contents: {e}") if not os.path.isdir(install_dir): create = MessageService.question(self, "Create Directory?", @@ -118,18 +117,15 @@ class TTWWorkflowMixin: self.cancel_btn.setVisible(False) self.cancel_install_btn.setVisible(True) - logger.debug(f'DEBUG: Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}') + logger.debug(f"Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}") self.run_ttw_installer(mpi_path, install_dir) except Exception as e: - logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}") - logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}") self._enable_controls_after_operation() self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) - logger.debug("DEBUG: Controls re-enabled in exception handler") def run_ttw_installer(self, mpi_path, install_dir): - logger.debug('DEBUG: run_ttw_installer called - USING THREADED BACKEND WRAPPER') + logger.debug("run_ttw_installer called - USING THREADED BACKEND WRAPPER") self.config_handler._load_config() @@ -141,11 +137,11 @@ class TTWWorkflowMixin: self._safe_append_text("Starting TTW installation...") self.file_progress_list.clear() - self._update_ttw_phase("Initializing TTW installation", 0, 0, 0) + self._update_ttw_phase("Initialising TTW installation", 0, 0, 0) QApplication.processEvents() self.status_banner.setVisible(True) - self.status_banner.setText("Initializing TTW installation...") + self.status_banner.setText("Initialising TTW installation...") self.show_details_checkbox.setVisible(True) self.status_banner.setStyleSheet(f""" @@ -178,7 +174,7 @@ class TTWWorkflowMixin: def on_installation_finished(self, success, message): """Handle installation completion.""" - logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}") + logger.debug(f"on_installation_finished called with success={success}, message={message}") if hasattr(self, 'ttw_elapsed_timer'): self.ttw_elapsed_timer.stop() @@ -189,8 +185,8 @@ class TTWWorkflowMixin: seconds = elapsed % 60 self.status_banner.setText(f"Installation completed successfully! Total time: {minutes}m {seconds}s") self.status_banner.setStyleSheet(""" - background-color: #1a4d1a; - color: #4CAF50; + background-color: #1a3040; + color: #3fd0ea; padding: 8px; border-radius: 4px; font-weight: bold; @@ -220,11 +216,11 @@ class TTWWorkflowMixin: self.process_finished(1, QProcess.CrashExit) def process_finished(self, exit_code, exit_status): - logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}") + logger.debug(f"process_finished called with exit_code={exit_code}, exit_status={exit_status}") self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) - logger.debug("DEBUG: Button states reset in process_finished") + logger.debug("Button states reset in process_finished") if exit_code == 0: self._safe_append_text("\nTTW installation completed successfully!") diff --git a/jackify/frontends/gui/screens/install_verifier_mixin.py b/jackify/frontends/gui/screens/install_verifier_mixin.py new file mode 100644 index 0000000..337d172 --- /dev/null +++ b/jackify/frontends/gui/screens/install_verifier_mixin.py @@ -0,0 +1,183 @@ +"""Mixin that runs verify_install.py before showing the success dialog.""" + +import logging +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import QThread, Signal + +logger = logging.getLogger(__name__) + + +class VerifierThread(QThread): + finished = Signal(object) + + def __init__(self, pfx: Path, modlist_dir: Path, game_type: str, appid: str, modlist_name: str = "", parent=None): + super().__init__(parent) + self.pfx = pfx + self.modlist_dir = modlist_dir + self.game_type = game_type + self.appid = appid + self.modlist_name = modlist_name + + def run(self): + try: + from jackify.backend.services.install_verifier_service import run_install_verification + results = run_install_verification(self.pfx, self.modlist_dir, self.game_type, self.appid, self.modlist_name) + except Exception as e: + logger.warning("Verifier thread error: %s", e) + results = None + self.finished.emit(results) + + +def _resolve_pfx_for_appid(appid: str) -> Optional[Path]: + from jackify.backend.services.install_verifier_service import resolve_pfx_for_appid + return resolve_pfx_for_appid(appid) + + +class InstallVerifierMixin: + """Mixin: run the verifier before showing the success dialog.""" + + def _get_appid_for_install_dir(self, install_dir: str) -> str: + stored = getattr(self, "_current_appid", "") or "" + if stored: + return stored + try: + import os + from jackify.backend.handlers.shortcut_handler import ShortcutHandler + from jackify.backend.services.platform_detection_service import PlatformDetectionService + platform_service = PlatformDetectionService.get_instance() + sh = ShortcutHandler(steamdeck=platform_service.is_steamdeck, verbose=False) + for sc in sh.find_shortcuts_by_exe("ModOrganizer.exe"): + if os.path.realpath(sc.get("StartDir", "")) == os.path.realpath(install_dir): + raw = sc.get("appid") + if raw is not None: + return str(int(raw) & 0xFFFFFFFF) + except Exception as e: + logger.debug("AppID lookup failed: %s", e) + return "" + + def _maybe_apply_jcontainers_fix(self, install_dir: str, game_type: str) -> None: + """Apply the JContainers Linux fix if needed, with a countdown confirmation dialog.""" + try: + from jackify.backend.handlers.modlist_fixup_handler import ( + check_jcontainers_needs_fix, + apply_jcontainers_fix, + ) + needs_fix = check_jcontainers_needs_fix(Path(install_dir), game_type) + if not needs_fix: + return + + from jackify.frontends.gui.services.message_service import SafeMessageBox + from PySide6.QtWidgets import QMessageBox + dlg = SafeMessageBox(parent=self, safety_level="low") + dlg.setup_safety_features( + title="JContainers Compatibility Fix", + message=( + "The mod JContainers has been detected. The Nexusmods version of " + "JContainers is known to cause crashes on Linux/Proton.\n\n" + "A fixed version is available from the mod's GitHub page - would you " + "like the fixed version to be applied now?\n\n" + "The original DLL will be backed up as part of the process." + ), + danger_action="Yes", + safe_action="No", + is_question=True, + ) + result = dlg.exec() + if result == QMessageBox.Yes: + apply_jcontainers_fix(Path(install_dir), game_type) + logger.info("JContainers fix applied post-configure") + except Exception as e: + logger.warning("JContainers fix check failed (non-fatal): %s", e) + + def _run_verifier_then_show_success( + self, + install_dir: str, + game_type: str, + success_params: dict, + appid: str = "", + ): + """ + Show 'Verifying...' state, run the verifier in a background thread, + then show SuccessDialog with results embedded. + + success_params keys: modlist_name, workflow_type, time_taken, game_name, enb_detected + """ + self._maybe_apply_jcontainers_fix(install_dir, game_type) + if hasattr(self, "progress_indicator"): + self.progress_indicator.set_status("Verifying installation...", 100) + if hasattr(self, "file_progress_list"): + self.file_progress_list.update_or_add_item( + "__verifier__", "Verifying installation...", 0.0 + ) + + resolved_appid = str(appid or self._get_appid_for_install_dir(install_dir) or "") + pfx = _resolve_pfx_for_appid(resolved_appid) + + if not install_dir or pfx is None: + logger.info( + "Verifier skipped: pfx not found (appid=%s dir=%s)", + resolved_appid, install_dir, + ) + self._show_success_dialog(success_params, verification_results=None) + return + + self._verifier_thread = VerifierThread( + pfx=pfx, + modlist_dir=Path(install_dir), + game_type=game_type, + appid=resolved_appid, + modlist_name=success_params.get("modlist_name", ""), + parent=self, + ) + self._verifier_thread.finished.connect( + lambda r: self._on_verifier_complete_show_success(r, success_params) + ) + self._verifier_thread.start() + + def _on_verifier_complete_show_success(self, results, success_params: dict): + if self._verifier_thread is not None: + self._verifier_thread.wait(2000) + self._verifier_thread.deleteLater() + self._verifier_thread = None + if results is not None: + n_pass = len(results.passes) + n_warn = len(results.warnings) + n_fail = len(results.failures) + logger.info( + "Install verification: %d passed, %d warnings, %d failures", + n_pass, n_warn, n_fail, + ) + for msg in results.failures: + logger.warning("Verifier FAIL: %s", msg) + for msg in results.warnings: + logger.info("Verifier WARN: %s", msg) + else: + logger.warning("Install verifier returned no results (script error)") + + self._show_success_dialog(success_params, verification_results=results) + + def _show_success_dialog(self, params: dict, verification_results=None): + """Clear the activity window and show SuccessDialog with optional verification results.""" + if hasattr(self, "file_progress_list"): + self.file_progress_list.clear() + + from jackify.frontends.gui.dialogs import SuccessDialog + dlg = SuccessDialog( + modlist_name=params["modlist_name"], + workflow_type=params["workflow_type"], + time_taken=params["time_taken"], + game_name=params.get("game_name"), + verification_results=verification_results, + parent=self, + ) + dlg.show() + + if params.get("enb_detected"): + try: + from jackify.frontends.gui.dialogs.enb_proton_dialog import ENBProtonDialog + enb_dialog = ENBProtonDialog(modlist_name=params["modlist_name"], parent=self) + enb_dialog.exec() + except Exception as e: + logger.warning("Failed to show ENB dialog: %s", e) diff --git a/jackify/frontends/gui/screens/main_menu.py b/jackify/frontends/gui/screens/main_menu.py index 644438b..001da19 100644 --- a/jackify/frontends/gui/screens/main_menu.py +++ b/jackify/frontends/gui/screens/main_menu.py @@ -8,11 +8,18 @@ import os from ..shared_theme import JACKIFY_COLOR_BLUE, LOGO_PATH, DISCLAIMER_TEXT from ..utils import set_responsive_minimum +_TOOLS_HUB_ACTION = "third_party_tools" +_UPDATE_COLOUR = "#f0c040" +_NORMAL_DESC_COLOUR = "#999" + class MainMenu(QWidget): def __init__(self, stacked_widget=None, dev_mode=False): super().__init__() self.stacked_widget = stacked_widget self.dev_mode = dev_mode + self._tools_hub_btn: QPushButton = None + self._tools_hub_desc: QLabel = None + self._tools_hub_desc_original: str = "" layout = QVBoxLayout() layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter) layout.setContentsMargins(30, 30, 30, 30) @@ -63,35 +70,17 @@ class MainMenu(QWidget): button_height = 40 MENU_ITEMS = [ ("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"), - ("Additional Tasks", "additional_tasks", "Additional Tasks & Tools, such as TTW Installation"), - # ("Third Party Tools", "third_party_tools", "Install and manage Sulfur's Linux-native modding tools"), # v0.7 + ("Additional Tasks", "additional_tasks", "Verifier, diagnostics, Nexus OAuth, and more"), + ("Tools Hub", "third_party_tools", "Install and manage additional engines and modding tools"), ("Exit Jackify", "exit_jackify", "Close the application"), ] for label, action_id, description in MENU_ITEMS: - # Main button btn = QPushButton(label) - btn.setFixedSize(button_width, button_height) # Use variable height - btn.setStyleSheet(f""" - QPushButton {{ - background-color: #4a5568; - color: white; - border: none; - border-radius: 6px; - font-size: 13px; - font-weight: bold; - text-align: center; - }} - QPushButton:hover {{ - background-color: #5a6578; - }} - QPushButton:pressed {{ - background-color: {JACKIFY_COLOR_BLUE}; - }} - """) + btn.setFixedSize(button_width, button_height) + btn.setStyleSheet(self._btn_style()) btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a)) - # Button container with proper alignment btn_container = QWidget() btn_layout = QVBoxLayout() btn_layout.setContentsMargins(0, 0, 0, 0) @@ -99,10 +88,9 @@ class MainMenu(QWidget): btn_layout.setAlignment(Qt.AlignHCenter) btn_layout.addWidget(btn) - # Description label with proper alignment desc_label = QLabel(description) desc_label.setAlignment(Qt.AlignHCenter) - desc_label.setStyleSheet("color: #999; font-size: 11px;") + desc_label.setStyleSheet(f"color: {_NORMAL_DESC_COLOUR}; font-size: 11px;") desc_label.setWordWrap(True) desc_label.setFixedWidth(button_width) btn_layout.addWidget(desc_label) @@ -110,6 +98,11 @@ class MainMenu(QWidget): btn_container.setLayout(btn_layout) layout.addWidget(btn_container) + if action_id == _TOOLS_HUB_ACTION: + self._tools_hub_btn = btn + self._tools_hub_desc = desc_label + self._tools_hub_desc_original = description + # Disclaimer layout.addSpacing(12) disclaimer = QLabel(DISCLAIMER_TEXT) @@ -135,6 +128,34 @@ class MainMenu(QWidget): except Exception: pass + def _btn_style(self, highlight: bool = False) -> str: + border = f"1px solid {_UPDATE_COLOUR}" if highlight else "none" + return f""" + QPushButton {{ + background-color: #4a5568; + color: white; + border: {border}; + border-radius: 6px; + font-size: 13px; + font-weight: bold; + text-align: center; + }} + QPushButton:hover {{ background-color: #5a6578; }} + QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }} + """ + + def notify_tool_updates(self, has_updates: bool) -> None: + if not self._tools_hub_btn or not self._tools_hub_desc: + return + if has_updates: + self._tools_hub_btn.setStyleSheet(self._btn_style(highlight=True)) + self._tools_hub_desc.setText("Updates available") + self._tools_hub_desc.setStyleSheet(f"color: {_UPDATE_COLOUR}; font-size: 11px; font-weight: bold;") + else: + self._tools_hub_btn.setStyleSheet(self._btn_style(highlight=False)) + self._tools_hub_desc.setText(self._tools_hub_desc_original) + self._tools_hub_desc.setStyleSheet(f"color: {_NORMAL_DESC_COLOUR}; font-size: 11px;") + def menu_action(self, action_id): if action_id == "exit_jackify": from PySide6.QtWidgets import QApplication diff --git a/jackify/frontends/gui/screens/modlist_tasks.py b/jackify/frontends/gui/screens/modlist_tasks.py index 4d64ead..206d157 100644 --- a/jackify/frontends/gui/screens/modlist_tasks.py +++ b/jackify/frontends/gui/screens/modlist_tasks.py @@ -137,7 +137,7 @@ class ModlistTasksScreen(QWidget): """Set up the menu buttons section""" # Menu options MENU_ITEMS = [ - ("Install a Modlist (Automated)", "install_modlist", "Download and install modlists automatically"), + ("Install a Modlist", "install_modlist", "Download and install modlists automatically"), ("Configure New Modlist (Post-Download)", "configure_new_modlist", "Configure a newly downloaded modlist"), ("Configure Existing Modlist (In Steam)", "configure_existing_modlist", "Reconfigure an existing Steam modlist"), ] diff --git a/jackify/frontends/gui/screens/third_party_tools.py b/jackify/frontends/gui/screens/third_party_tools.py deleted file mode 100644 index a2d7a43..0000000 --- a/jackify/frontends/gui/screens/third_party_tools.py +++ /dev/null @@ -1,478 +0,0 @@ -""" -Third Party Tools screen. - -Lists independently-managed tools with install status, version info, -and Install / Update / Downgrade / Uninstall actions per tool. -Version checks run in a background thread so the screen loads instantly. -""" - -import logging -from typing import Dict, Optional - -from PySide6.QtCore import Qt, QThread, Signal -from PySide6.QtWidgets import ( - QFrame, QHBoxLayout, QLabel, QPushButton, - QScrollArea, QSizePolicy, QVBoxLayout, QWidget, -) - -from jackify.backend.services.tool_registry import TOOL_DEFINITIONS, ToolRegistry, ToolStatus -from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin -from jackify.frontends.gui.services.message_service import MessageService -from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE -from jackify.frontends.gui.utils import set_responsive_minimum - -logger = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Colours -# --------------------------------------------------------------------------- -_BTN_INSTALL = "#1a5fa8" -_BTN_UPDATE = "#2a6e2a" -_BTN_DOWNGRADE = "#7a5a00" -_BTN_UNINSTALL = "#6b2020" -_BTN_DISABLED = "#333" - -_BADGE_NOT_INSTALLED = ("#555", "#ccc") # bg, fg -_BADGE_UP_TO_DATE = ("#1e4d1e", "#8fdc8f") -_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040") -_BADGE_CHECKING = ("#333", "#888") - - -def _btn_style(colour: str, disabled: bool = False) -> str: - bg = _BTN_DISABLED if disabled else colour - return f""" - QPushButton {{ - background-color: {bg}; - color: {'#666' if disabled else 'white'}; - border: none; border-radius: 4px; - font-size: 11px; font-weight: bold; - padding: 4px 8px; - }} - QPushButton:hover {{ background-color: {'#444' if disabled else bg}; }} - QPushButton:pressed {{ background-color: {bg}; }} - """ - - -# --------------------------------------------------------------------------- -# Background version-check thread -# --------------------------------------------------------------------------- - -class _VersionCheckThread(QThread): - version_ready = Signal(str, str) # tool_id, latest_version_tag - - def run(self): - registry = ToolRegistry() - for defn in TOOL_DEFINITIONS: - try: - tag = registry.check_latest_version(defn.tool_id) - if tag: - self.version_ready.emit(defn.tool_id, tag) - except Exception as e: - logger.debug("Version check failed for %s: %s", defn.tool_id, e) - - -# --------------------------------------------------------------------------- -# Background install/update/downgrade/uninstall thread -# --------------------------------------------------------------------------- - -class _ToolActionThread(QThread): - finished_signal = Signal(str, bool, str) # tool_id, success, message - - def __init__(self, tool_id: str, action: str): - super().__init__() - self._tool_id = tool_id - self._action = action - - def run(self): - registry = ToolRegistry() - try: - if self._action == "install": - ok, msg = registry.install(self._tool_id) - elif self._action == "update": - ok, msg = registry.update(self._tool_id) - elif self._action == "downgrade": - ok, msg = registry.downgrade(self._tool_id) - elif self._action == "uninstall": - ok, msg = registry.uninstall(self._tool_id) - else: - ok, msg = False, f"Unknown action: {self._action}" - except Exception as e: - ok, msg = False, str(e) - self.finished_signal.emit(self._tool_id, ok, msg) - - -# --------------------------------------------------------------------------- -# Per-tool card widget -# --------------------------------------------------------------------------- - -class _ToolCard(QFrame): - action_requested = Signal(str, str) # tool_id, action - - def __init__(self, status: ToolStatus, parent=None): - super().__init__(parent) - self._tool_id = status.definition.tool_id - self._status = status - self._busy = False - - self.setFrameShape(QFrame.StyledPanel) - self.setStyleSheet(""" - QFrame { - background-color: #2a2a2a; - border: 1px solid #3a3a3a; - border-radius: 6px; - } - """) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - outer = QHBoxLayout() - outer.setContentsMargins(14, 10, 14, 10) - outer.setSpacing(12) - - # --- Left: name + description --- - info_col = QVBoxLayout() - info_col.setSpacing(2) - - tier_tag = " [required]" if status.definition.tier == 1 else "" - name_label = QLabel(f"{status.definition.display_name}{tier_tag}") - name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;") - info_col.addWidget(name_label) - - desc_label = QLabel(status.definition.description) - desc_label.setWordWrap(True) - desc_label.setStyleSheet("color: #888; font-size: 11px; background: transparent; border: none;") - info_col.addWidget(desc_label) - - info_widget = QWidget() - info_widget.setLayout(info_col) - info_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - info_widget.setStyleSheet("background: transparent; border: none;") - outer.addWidget(info_widget, stretch=3) - - # --- Centre: status badge + version --- - centre_col = QVBoxLayout() - centre_col.setSpacing(4) - centre_col.setAlignment(Qt.AlignCenter) - - self._badge = QLabel() - self._badge.setAlignment(Qt.AlignCenter) - self._badge.setFixedWidth(130) - self._badge.setStyleSheet("border-radius: 3px; padding: 2px 6px; font-size: 11px; font-weight: bold;") - centre_col.addWidget(self._badge, alignment=Qt.AlignCenter) - - self._version_label = QLabel() - self._version_label.setAlignment(Qt.AlignCenter) - self._version_label.setStyleSheet("color: #777; font-size: 10px; background: transparent; border: none;") - centre_col.addWidget(self._version_label, alignment=Qt.AlignCenter) - - centre_widget = QWidget() - centre_widget.setLayout(centre_col) - centre_widget.setFixedWidth(150) - centre_widget.setStyleSheet("background: transparent; border: none;") - outer.addWidget(centre_widget) - - # --- Right: action buttons --- - btn_col = QVBoxLayout() - btn_col.setSpacing(4) - btn_col.setAlignment(Qt.AlignCenter) - - self._btn_primary = QPushButton() - self._btn_primary.setFixedWidth(90) - self._btn_primary.clicked.connect(self._on_primary) - btn_col.addWidget(self._btn_primary) - - self._btn_downgrade = QPushButton("Downgrade") - self._btn_downgrade.setFixedWidth(90) - self._btn_downgrade.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "downgrade")) - btn_col.addWidget(self._btn_downgrade) - - self._btn_uninstall = QPushButton("Uninstall") - self._btn_uninstall.setFixedWidth(90) - self._btn_uninstall.clicked.connect(self._on_uninstall) - btn_col.addWidget(self._btn_uninstall) - - btn_widget = QWidget() - btn_widget.setLayout(btn_col) - btn_widget.setFixedWidth(110) - btn_widget.setStyleSheet("background: transparent; border: none;") - outer.addWidget(btn_widget) - - self.setLayout(outer) - self._refresh_ui(status) - - # ------------------------------------------------------------------ - - def _refresh_ui(self, status: ToolStatus): - self._status = status - installed = status.installed - update_avail = status.update_available - can_downgrade = status.can_downgrade - can_uninstall = status.definition.can_uninstall - - # Badge - if not installed: - bg, fg = _BADGE_NOT_INSTALLED - badge_text = "Not Installed" - elif update_avail: - bg, fg = _BADGE_UPDATE_AVAIL - badge_text = "Update Available" - else: - bg, fg = _BADGE_UP_TO_DATE - badge_text = "Installed" - self._badge.setText(badge_text) - self._badge.setStyleSheet( - f"background-color: {bg}; color: {fg}; border-radius: 3px; " - f"padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;" - ) - - # Version line - installed_ver = status.installed_version or "-" - latest_ver = status.latest_version or "checking..." - if installed: - self._version_label.setText(f"Installed: {installed_ver}\nLatest: {latest_ver}") - else: - self._version_label.setText(f"Latest: {latest_ver}") - - # Primary button - if not installed: - self._btn_primary.setText("Install") - self._btn_primary.setStyleSheet(_btn_style(_BTN_INSTALL)) - self._btn_primary.setEnabled(True) - elif update_avail: - self._btn_primary.setText("Update") - self._btn_primary.setStyleSheet(_btn_style(_BTN_UPDATE)) - self._btn_primary.setEnabled(True) - else: - self._btn_primary.setText("Reinstall") - self._btn_primary.setStyleSheet(_btn_style(_BTN_INSTALL)) - self._btn_primary.setEnabled(True) - - # Downgrade button - self._btn_downgrade.setStyleSheet(_btn_style(_BTN_DOWNGRADE, disabled=not can_downgrade)) - self._btn_downgrade.setEnabled(can_downgrade and not self._busy) - - # Uninstall button - self._btn_uninstall.setVisible(can_uninstall) - if can_uninstall: - self._btn_uninstall.setStyleSheet(_btn_style(_BTN_UNINSTALL, disabled=not installed)) - self._btn_uninstall.setEnabled(installed and not self._busy) - - if self._busy: - self._btn_primary.setEnabled(False) - self._btn_primary.setStyleSheet(_btn_style(_BTN_DISABLED, disabled=True)) - - def set_latest_version(self, tag: str): - self._status.latest_version = tag - if self._status.installed and self._status.installed_version: - installed = self._status.installed_version.lstrip("v") - latest = tag.lstrip("v") - self._status.update_available = latest != installed - self._refresh_ui(self._status) - - def set_busy(self, busy: bool, label: Optional[str] = None): - self._busy = busy - if busy and label: - self._btn_primary.setText(label) - self._refresh_ui(self._status) - - def mark_installed(self, version: str): - self._status.installed = True - self._status.installed_version = version - self._status.update_available = False - self._busy = False - self._refresh_ui(self._status) - - def mark_uninstalled(self): - self._status.installed = False - self._status.installed_version = None - self._status.update_available = False - self._busy = False - self._refresh_ui(self._status) - - # ------------------------------------------------------------------ - - def _on_primary(self): - if not self._status.installed: - self.action_requested.emit(self._tool_id, "install") - elif self._status.update_available: - self.action_requested.emit(self._tool_id, "update") - else: - self.action_requested.emit(self._tool_id, "install") - - def _on_uninstall(self): - confirmed = MessageService.question( - self, - "Uninstall Tool", - f"Uninstall {self._status.definition.display_name}?\n\nThis will delete the installed files.", - ) - if confirmed: - self.action_requested.emit(self._tool_id, "uninstall") - - -# --------------------------------------------------------------------------- -# Main screen -# --------------------------------------------------------------------------- - -class ThirdPartyToolsScreen(ThreadLifecycleMixin, QWidget): - """Third Party Tools management screen.""" - - def __init__(self, stacked_widget=None, main_menu_index: int = 0, parent=None): - super().__init__(parent) - self.stacked_widget = stacked_widget - self.main_menu_index = main_menu_index - - self._cards: Dict[str, _ToolCard] = {} - self._action_thread: Optional[_ToolActionThread] = None - self._version_thread: Optional[_VersionCheckThread] = None - - self._setup_ui() - - def _setup_ui(self): - root = QVBoxLayout() - root.setContentsMargins(30, 24, 30, 24) - root.setSpacing(0) - self.setLayout(root) - - # Header - title = QLabel("Third Party Tools") - title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};") - title.setAlignment(Qt.AlignHCenter) - root.addWidget(title) - - root.addSpacing(6) - - desc = QLabel( - "Install and manage independently-updated tools used by Jackify workflows or run via MO2.\n" - "Tools marked [required] are needed by existing Jackify workflows." - ) - desc.setWordWrap(True) - desc.setStyleSheet("color: #aaa; font-size: 12px;") - desc.setAlignment(Qt.AlignHCenter) - root.addWidget(desc) - - root.addSpacing(10) - - sep = QLabel() - sep.setFixedHeight(1) - sep.setStyleSheet("background: #444;") - root.addWidget(sep) - - root.addSpacing(12) - - # Scrollable tool list - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.NoFrame) - scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }") - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - list_widget = QWidget() - list_widget.setStyleSheet("background: transparent;") - self._list_layout = QVBoxLayout() - self._list_layout.setContentsMargins(0, 0, 0, 0) - self._list_layout.setSpacing(8) - list_widget.setLayout(self._list_layout) - - registry = ToolRegistry() - for status in registry.get_all_statuses(): - card = _ToolCard(status) - card.action_requested.connect(self._on_action) - self._cards[status.definition.tool_id] = card - self._list_layout.addWidget(card) - - self._list_layout.addStretch() - scroll.setWidget(list_widget) - root.addWidget(scroll, stretch=1) - - root.addSpacing(12) - - # Back button - back_row = QHBoxLayout() - back_row.addStretch() - back_btn = QPushButton("Back to Main Menu") - back_btn.setFixedSize(160, 34) - back_btn.setStyleSheet(f""" - QPushButton {{ - background-color: #4a5568; color: white; - border: none; border-radius: 5px; - font-size: 12px; font-weight: bold; - }} - QPushButton:hover {{ background-color: #5a6578; }} - QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }} - """) - back_btn.clicked.connect(self._go_back) - back_row.addWidget(back_btn) - back_row.addStretch() - root.addLayout(back_row) - - # ------------------------------------------------------------------ - # Version check on show - # ------------------------------------------------------------------ - - def showEvent(self, event): - super().showEvent(event) - try: - main_window = self.window() - if main_window: - set_responsive_minimum(main_window, min_width=960, min_height=520) - except Exception: - pass - self._start_version_check() - - def _start_version_check(self): - if self._version_thread and self._version_thread.isRunning(): - return - self._version_thread = _VersionCheckThread() - self._version_thread.version_ready.connect(self._on_version_ready) - self._version_thread.start() - - def _on_version_ready(self, tool_id: str, tag: str): - card = self._cards.get(tool_id) - if card: - card.set_latest_version(tag) - - # ------------------------------------------------------------------ - # Action dispatch - # ------------------------------------------------------------------ - - def _on_action(self, tool_id: str, action: str): - if self._action_thread and self._action_thread.isRunning(): - MessageService.information(self, "Busy", "Another operation is already running. Please wait.") - return - - card = self._cards.get(tool_id) - if card: - label_map = {"install": "Installing...", "update": "Updating...", - "downgrade": "Downgrading...", "uninstall": "Removing..."} - card.set_busy(True, label_map.get(action, "Working...")) - - self._action_thread = _ToolActionThread(tool_id, action) - self._action_thread.finished_signal.connect(self._on_action_finished) - self._action_thread.start() - - def _on_action_finished(self, tool_id: str, success: bool, message: str): - self._action_thread = None - - card = self._cards.get(tool_id) - if success: - registry = ToolRegistry() - status = registry.get_status(tool_id) - if status and status.installed and card: - card.mark_installed(status.installed_version or "") - if status.latest_version: - card.set_latest_version(status.latest_version) - elif card: - card.mark_uninstalled() - MessageService.information(self, "Done", message) - else: - if card: - card.set_busy(False) - MessageService.warning(self, "Failed", message) - - # ------------------------------------------------------------------ - - def _go_back(self): - if self.stacked_widget: - self.stacked_widget.setCurrentIndex(self.main_menu_index) - - def cleanup_processes(self): - self._park_all_threads() diff --git a/jackify/frontends/gui/screens/tools_hub.py b/jackify/frontends/gui/screens/tools_hub.py new file mode 100644 index 0000000..3eba38c --- /dev/null +++ b/jackify/frontends/gui/screens/tools_hub.py @@ -0,0 +1,469 @@ +""" +Tools Hub screen. + +Manages independently-versioned engines and tools. On each show, the tool list +is rebuilt from the effective definitions (remote manifest if fetched, else +baked-in). A background thread fetches the manifest; if the tool list changes +the cards are rebuilt and version checks restart. +""" + +import logging +from typing import Dict, List, Optional + +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtWidgets import ( + QComboBox, QDialog, QDialogButtonBox, QFrame, QHBoxLayout, QLabel, + QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, +) + +from jackify.backend.services.tool_registry import ( + ToolDefinition, ToolRegistry, ToolStatus, + apply_remote_manifest, fetch_remote_manifest, fetch_release_list, + get_active_engine_id, get_effective_definitions, +) +from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin +from jackify.frontends.gui.screens.tools_hub_card import ToolCard, btn_style, section_header +from jackify.frontends.gui.services.message_service import MessageService +from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE +from jackify.frontends.gui.utils import set_responsive_minimum + +logger = logging.getLogger(__name__) + +_C_UPDATE = "#4a5568" +_C_BACK = "#4a5568" +_C_INSTALL = "#1a5fa8" + + +# -- background threads ------------------------------------------------------ +class _VersionCheckThread(QThread): + version_ready = Signal(str, str) # tool_id, latest_tag + + def run(self): + registry = ToolRegistry() + for defn in get_effective_definitions(): + try: + tag = registry.check_latest_version(defn.tool_id) + self.version_ready.emit(defn.tool_id, tag or "unknown") + except Exception as e: + logger.debug("Version check failed for %s: %s", defn.tool_id, e) + self.version_ready.emit(defn.tool_id, "unknown") + + +class _ToolActionThread(QThread): + finished_signal = Signal(str, bool, str) # tool_id, success, message + + def __init__(self, tool_id: str, action: str, version: Optional[str] = None): + super().__init__() + self._tool_id = tool_id + self._action = action + self._version = version + + def run(self): + registry = ToolRegistry() + try: + if self._action == "install": + ok, msg = registry.install(self._tool_id, version=self._version) + elif self._action == "update": + ok, msg = registry.update(self._tool_id) + elif self._action == "uninstall": + ok, msg = registry.uninstall(self._tool_id) + else: + ok, msg = False, f"Unknown action: {self._action}" + except Exception as e: + ok, msg = False, str(e) + self.finished_signal.emit(self._tool_id, ok, msg) + + +class _ManifestFetchThread(QThread): + manifest_ready = Signal(list) # List[ToolDefinition] + + def run(self): + result = fetch_remote_manifest() + if result: + self.manifest_ready.emit(result) + + +class _ReleaseFetchThread(QThread): + releases_ready = Signal(str, list) # tool_id, List[dict] + + def __init__(self, tool_id: str, github_repo: str): + super().__init__() + self._tool_id = tool_id + self._github_repo = github_repo + + def run(self): + releases = fetch_release_list(self._github_repo) + self.releases_ready.emit(self._tool_id, releases) + + +# -- main screen ------------------------------------------------------------- +class ToolsHubScreen(ThreadLifecycleMixin, QWidget): + """Tools Hub: engine selection and third-party tool management.""" + + def __init__(self, stacked_widget=None, main_menu_index: int = 0, ttw_screen_index: int = 5, parent=None): + super().__init__(parent) + self.stacked_widget = stacked_widget + self.main_menu_index = main_menu_index + self.ttw_screen_index = ttw_screen_index + + self._cards: Dict[str, ToolCard] = {} + self._action_thread: Optional[_ToolActionThread] = None + self._version_thread: Optional[_VersionCheckThread] = None + self._manifest_thread: Optional[_ManifestFetchThread] = None + self._release_thread: Optional[_ReleaseFetchThread] = None + self._active_engine_id = get_active_engine_id() + + self._setup_ui() + + def _setup_ui(self): + root = QVBoxLayout() + root.setContentsMargins(30, 24, 30, 24) + root.setSpacing(0) + self.setLayout(root) + + header_row = QHBoxLayout() + self._btn_update_all = QPushButton("Update All") + self._btn_update_all.setFixedSize(100, 30) + self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True)) + self._btn_update_all.setEnabled(False) + self._btn_update_all.clicked.connect(self._on_update_all) + # Left spacer matches button width so title stays centred + left_spacer = QWidget() + left_spacer.setFixedWidth(100) + header_row.addWidget(left_spacer) + header_row.addStretch() + title = QLabel("Tools Hub") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};") + header_row.addWidget(title) + header_row.addStretch() + header_row.addWidget(self._btn_update_all) + root.addLayout(header_row) + + root.addSpacing(6) + + disclaimer = QLabel( + "Some of these tools are developed and maintained by their respective authors, " + "independently of Jackify. Jackify provides download and update management " + "as a convenience only. The Jackify project offers no warranty or support " + "for third-party tools." + ) + disclaimer.setWordWrap(True) + disclaimer.setStyleSheet("color: #aaa; font-size: 12px;") + root.addWidget(disclaimer) + + root.addSpacing(10) + sep = QLabel() + sep.setFixedHeight(2) + sep.setStyleSheet("background: #fff;") + root.addWidget(sep) + root.addSpacing(12) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.NoFrame) + scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }") + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + self._list_widget = QWidget() + self._list_widget.setStyleSheet("background: transparent;") + self._list_layout = QVBoxLayout() + self._list_layout.setContentsMargins(0, 0, 0, 0) + self._list_layout.setSpacing(6) + self._list_widget.setLayout(self._list_layout) + + scroll.setWidget(self._list_widget) + root.addWidget(scroll, stretch=1) + + root.addSpacing(12) + back_row = QHBoxLayout() + back_row.addStretch() + back_btn = QPushButton("Back to Main Menu") + back_btn.setFixedSize(160, 34) + back_btn.setStyleSheet( + f"QPushButton {{ background-color: {_C_BACK}; color: white; border: none; " + f"border-radius: 6px; font-size: 12px; font-weight: bold; }}" + f"QPushButton:hover {{ background-color: #5a6578; }}" + f"QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }}" + ) + back_btn.clicked.connect(self._go_back) + back_row.addWidget(back_btn) + back_row.addStretch() + root.addLayout(back_row) + + # card list management + + def _rebuild_card_list(self): + while self._list_layout.count(): + item = self._list_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + self._cards.clear() + + statuses = ToolRegistry().get_all_statuses() + engines = [s for s in statuses if s.definition.is_engine] + tools = [s for s in statuses if not s.definition.is_engine] + + if engines: + self._list_layout.addWidget(section_header("Engine")) + self._list_layout.addSpacing(4) + for s in engines: + self._add_card(s) + self._list_layout.addSpacing(10) + + if tools: + self._list_layout.addWidget(section_header("Tools")) + self._list_layout.addSpacing(4) + for s in tools: + self._add_card(s) + + placeholder = QLabel("More tools coming soon") + placeholder.setAlignment(Qt.AlignCenter) + placeholder.setStyleSheet( + "color: #555; font-size: 12px; font-style: italic; " + "background-color: #222; border: 1px dashed #333; " + "border-radius: 6px; padding: 10px;" + ) + self._list_layout.addWidget(placeholder) + + self._list_layout.addStretch() + + def _add_card(self, status: ToolStatus): + card = ToolCard(status, self._active_engine_id) + card.action_requested.connect(self._on_action) + card.engine_activated.connect(self._on_engine_activated) + self._cards[status.definition.tool_id] = card + self._list_layout.addWidget(card) + + # show / manifest / version check + + def showEvent(self, event): + super().showEvent(event) + try: + mw = self.window() + if mw: + set_responsive_minimum(mw, min_width=960, min_height=520) + except Exception: + pass + self._active_engine_id = get_active_engine_id() + self._rebuild_card_list() + self._start_manifest_fetch() + self._start_version_check() + + def _start_manifest_fetch(self): + if self._manifest_thread and self._manifest_thread.isRunning(): + return + self._manifest_thread = _ManifestFetchThread() + self._manifest_thread.manifest_ready.connect(self._on_manifest_ready) + self._manifest_thread.start() + + def _on_manifest_ready(self, definitions: List[ToolDefinition]): + current_ids = set(self._cards.keys()) + new_ids = {d.tool_id for d in definitions} + apply_remote_manifest(definitions) + if current_ids != new_ids: + if self._version_thread and self._version_thread.isRunning(): + self._version_thread.quit() + self._rebuild_card_list() + self._start_version_check() + + def _start_version_check(self): + if self._version_thread and self._version_thread.isRunning(): + return + self._version_thread = _VersionCheckThread() + self._version_thread.version_ready.connect(self._on_version_ready) + self._version_thread.start() + + def _on_version_ready(self, tool_id: str, tag: str): + card = self._cards.get(tool_id) + if card: + has_update = card.set_latest_version(tag) + if has_update: + self._btn_update_all.setEnabled(True) + self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE)) + any_updates = any(c._status.update_available for c in self._cards.values()) + main_menu = self._get_main_menu() + if main_menu: + main_menu.notify_tool_updates(any_updates) + + def _get_main_menu(self): + try: + from jackify.frontends.gui.screens.main_menu import MainMenu + w = self.window() + if w and hasattr(w, 'main_menu'): + return w.main_menu + except Exception: + pass + return None + + # engine activation + + def _on_engine_activated(self, tool_id: str): + self._active_engine_id = tool_id + for card in self._cards.values(): + card.set_active_engine(tool_id) + card = self._cards.get(tool_id) + name = card._status.definition.display_name if card else tool_id + MessageService.information(self, "Engine Changed", f"{name} is now the active engine.") + + # action dispatch + + def _on_action(self, tool_id: str, action: str): + if action == "launch_jackify_ui": + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(self.ttw_screen_index) + ttw_screen = self.stacked_widget.widget(self.ttw_screen_index) + if hasattr(ttw_screen, 'main_menu_index'): + ttw_screen.main_menu_index = self.stacked_widget.indexOf(self) + return + if self._action_thread and self._action_thread.isRunning(): + MessageService.information(self, "Busy", "Another operation is running. Please wait.") + return + + if action == "downgrade": + self._start_downgrade_flow(tool_id) + return + + card = self._cards.get(tool_id) + if card: + label_map = { + "install": "Installing...", "update": "Updating...", "uninstall": "Removing...", + } + card.set_busy(True, label_map.get(action, "Working...")) + self._action_thread = _ToolActionThread(tool_id, action) + self._action_thread.finished_signal.connect(self._on_action_finished) + self._action_thread.start() + + def _on_action_finished(self, tool_id: str, success: bool, message: str): + self._action_thread = None + card = self._cards.get(tool_id) + if success: + status = ToolRegistry().get_status(tool_id) + if status and status.installed and card: + card.mark_installed(status.installed_version or "") + if status.latest_version: + card.set_latest_version(status.latest_version) + elif card: + card.mark_uninstalled() + if message: + MessageService.information(self, "Done", message) + else: + if card: + card.set_busy(False) + MessageService.warning(self, "Failed", message) + + def _on_update_all(self): + updates = [tid for tid, card in self._cards.items() + if card._status.installed and card._status.update_available] + if not updates: + return + names = ", ".join(self._cards[tid]._status.definition.display_name for tid in updates) + if MessageService.question(self, "Update All", f"Update the following tools?\n\n{names}") != QMessageBox.Yes: + return + self._pending_updates: List[str] = updates + self._run_next_update() + + def _run_next_update(self): + if not self._pending_updates: + return + tool_id = self._pending_updates.pop(0) + card = self._cards.get(tool_id) + if card: + card.set_busy(True, "Updating...") + self._action_thread = _ToolActionThread(tool_id, "update") + self._action_thread.finished_signal.connect(self._on_update_all_step) + self._action_thread.start() + + def _on_update_all_step(self, tool_id: str, success: bool, message: str): + self._action_thread = None + self._on_action_finished(tool_id, success, message if not success else "") + if getattr(self, "_pending_updates", []): + self._run_next_update() + else: + any_remaining = any(c._status.installed and c._status.update_available for c in self._cards.values()) + self._btn_update_all.setEnabled(any_remaining) + if not any_remaining: + self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True)) + + def _start_downgrade_flow(self, tool_id: str): + card = self._cards.get(tool_id) + status = ToolRegistry().get_status(tool_id) + if not status: + return + if card: + card.set_busy(True, "Fetching releases...") + self._release_thread = _ReleaseFetchThread(tool_id, status.definition.github_repo) + self._release_thread.releases_ready.connect(self._on_releases_ready) + self._release_thread.start() + + def _on_releases_ready(self, tool_id: str, releases: list): + card = self._cards.get(tool_id) + if card: + card.set_busy(False) + if not releases: + MessageService.warning(self, "Change Version", "Could not fetch release list from GitHub.") + return + status = ToolRegistry().get_status(tool_id) + current = status.installed_version if status else None + version = self._show_version_picker(tool_id, releases, current) + if not version: + return + if card: + card.set_busy(True, "Changing version...") + self._action_thread = _ToolActionThread(tool_id, "install", version=version) + self._action_thread.finished_signal.connect(self._on_action_finished) + self._action_thread.start() + + def _show_version_picker(self, tool_id: str, releases: list, current_version: Optional[str]) -> Optional[str]: + dlg = QDialog(self.window()) + dlg.setWindowTitle("Select Version") + dlg.setWindowModality(Qt.ApplicationModal) + dlg.setMinimumWidth(360) + dlg.setStyleSheet( + "QDialog { background-color: #232323; color: #e0e0e0; }" + "QLabel { color: #e0e0e0; background: transparent; border: none; }" + "QComboBox { background-color: #2a2a2a; color: #e0e0e0; border: 1px solid #444; " + " border-radius: 4px; padding: 4px 8px; }" + "QComboBox QAbstractItemView { background-color: #2a2a2a; color: #e0e0e0; " + " selection-background-color: #3a3a3a; }" + ) + layout = QVBoxLayout(dlg) + layout.setContentsMargins(20, 18, 20, 18) + layout.setSpacing(12) + if current_version: + lbl = QLabel(f"Currently installed: {current_version}") + lbl.setTextFormat(Qt.RichText) + layout.addWidget(lbl) + combo = QComboBox() + for rel in releases: + tag = rel.get("tag_name") or rel.get("name", "") + date = (rel.get("published_at") or "")[:10] + combo.addItem(f"{tag} ({date})", userData=tag) + layout.addWidget(combo) + sep = QLabel() + sep.setFixedHeight(1) + sep.setStyleSheet("background: #3a3a3a; border: none;") + layout.addWidget(sep) + btn_row = QHBoxLayout() + btn_row.addStretch() + cancel_btn = QPushButton("Cancel") + cancel_btn.setFixedSize(90, 30) + cancel_btn.setStyleSheet(btn_style(_C_BACK)) + cancel_btn.clicked.connect(dlg.reject) + btn_row.addWidget(cancel_btn) + install_btn = QPushButton("Install") + install_btn.setFixedSize(90, 30) + install_btn.setStyleSheet(btn_style(_C_INSTALL)) + install_btn.setDefault(True) + install_btn.clicked.connect(dlg.accept) + btn_row.addWidget(install_btn) + layout.addLayout(btn_row) + if dlg.exec() != QDialog.Accepted: + return None + return combo.currentData() + + def _go_back(self): + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(self.main_menu_index) + + def cleanup_processes(self): + self._park_all_threads() diff --git a/jackify/frontends/gui/screens/tools_hub_card.py b/jackify/frontends/gui/screens/tools_hub_card.py new file mode 100644 index 0000000..fc6fd82 --- /dev/null +++ b/jackify/frontends/gui/screens/tools_hub_card.py @@ -0,0 +1,325 @@ +""" +Tools Hub card widget. + +Per-tool card showing status badge, version, and action buttons. +Engines show Set Active / Active badge; tools with can_launch show Launch. +""" + +import logging +import subprocess +from typing import Optional + +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtWidgets import ( + QDialog, QFrame, QHBoxLayout, QLabel, + QMenu, QPushButton, QSizePolicy, QVBoxLayout, QWidget, +) + +from jackify.backend.services.tool_registry import ToolRegistry, ToolStatus, set_active_engine_id +from jackify.frontends.gui.services.message_service import MessageService +from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE + +logger = logging.getLogger(__name__) + +_C_INSTALL = "#1a5fa8" +_C_UPDATE = "#4a5568" +_C_LAUNCH = "#1a5fa8" +_C_SET_ACTIVE = "#4a5568" +_C_BACK = "#4a5568" +_C_DISABLED = "#333" + +_BADGE_NOT_INSTALLED = ("#555", "#ccc") +_BADGE_UP_TO_DATE = ("#1a3545", "#5fb8c8") +_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040") +_BADGE_ACTIVE = ("#0e3d5a", JACKIFY_COLOR_BLUE) + + +def btn_style(colour: str, disabled: bool = False, width: int = 90) -> str: + bg = _C_DISABLED if disabled else colour + hover = "#444" if disabled else colour + return ( + f"QPushButton {{ background-color: {bg}; color: {'#666' if disabled else 'white'}; " + f"border: none; border-radius: 4px; font-size: 11px; font-weight: bold; " + f"padding: 4px 8px; min-width: {width}px; }}" + f"QPushButton:hover {{ background-color: {hover}; }}" + ) + + +def section_header(text: str) -> QLabel: + lbl = QLabel(text.upper()) + lbl.setStyleSheet( + "color: #777; font-size: 10px; font-weight: bold; letter-spacing: 1px; " + "background: transparent; border: none; padding: 0;" + ) + return lbl + + +class ToolCard(QFrame): + action_requested = Signal(str, str) # tool_id, action + engine_activated = Signal(str) # tool_id + + def __init__(self, status: ToolStatus, active_engine_id: str, parent=None): + super().__init__(parent) + self._tool_id = status.definition.tool_id + self._status = status + self._active_engine_id = active_engine_id + self._busy = False + self._busy_label: Optional[str] = None + + self.setFrameShape(QFrame.StyledPanel) + self.setStyleSheet( + "QFrame { background-color: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 6px; }" + ) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + outer = QHBoxLayout() + outer.setContentsMargins(14, 7, 14, 7) + outer.setSpacing(12) + + info_col = QVBoxLayout() + info_col.setSpacing(2) + self._name_label = QLabel(f"{status.definition.display_name}") + self._name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;") + info_col.addWidget(self._name_label) + desc_label = QLabel(status.definition.description) + desc_label.setWordWrap(True) + desc_label.setStyleSheet("color: #888; font-size: 11px; background: transparent; border: none;") + info_col.addWidget(desc_label) + info_w = QWidget() + info_w.setLayout(info_col) + info_w.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + info_w.setStyleSheet("background: transparent; border: none;") + outer.addWidget(info_w, stretch=3) + + centre_col = QVBoxLayout() + centre_col.setSpacing(4) + centre_col.setAlignment(Qt.AlignCenter) + self._badge = QLabel() + self._badge.setAlignment(Qt.AlignCenter) + self._badge.setFixedWidth(140) + self._badge.setStyleSheet("border-radius: 3px; padding: 2px 6px; font-size: 11px; font-weight: bold;") + centre_col.addWidget(self._badge, alignment=Qt.AlignCenter) + self._version_label = QLabel() + self._version_label.setAlignment(Qt.AlignCenter) + self._version_label.setStyleSheet("color: #777; font-size: 10px; background: transparent; border: none;") + centre_col.addWidget(self._version_label, alignment=Qt.AlignCenter) + centre_w = QWidget() + centre_w.setLayout(centre_col) + centre_w.setFixedWidth(160) + centre_w.setStyleSheet("background: transparent; border: none;") + outer.addWidget(centre_w) + + btn_col = QVBoxLayout() + btn_col.setSpacing(3) + btn_col.setAlignment(Qt.AlignCenter) + self._btn_primary = QPushButton() + self._btn_primary.setFixedWidth(100) + self._btn_primary.clicked.connect(self._on_primary) + btn_col.addWidget(self._btn_primary) + self._btn_update = QPushButton("Update") + self._btn_update.setFixedWidth(100) + self._btn_update.setVisible(False) + self._btn_update.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "update")) + btn_col.addWidget(self._btn_update) + self._btn_more = QPushButton("...") + self._btn_more.setFixedWidth(100) + self._btn_more.setStyleSheet(btn_style(_C_BACK)) + self._btn_more.clicked.connect(self._on_more) + btn_col.addWidget(self._btn_more) + btn_w = QWidget() + btn_w.setLayout(btn_col) + btn_w.setFixedWidth(120) + btn_w.setStyleSheet("background: transparent; border: none;") + outer.addWidget(btn_w) + + self.setLayout(outer) + self._refresh_ui() + + def _refresh_ui(self): + defn = self._status.definition + installed = self._status.installed + update_avail = self._status.update_available + is_active = defn.is_engine and self._active_engine_id == self._tool_id + + if self._busy: + self._badge.setText("Working...") + self._badge.setStyleSheet( + "background-color: #555; color: #ccc; border-radius: 3px; " + "padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;" + ) + self._btn_primary.setText(self._busy_label or "Working...") + self._btn_primary.setEnabled(False) + self._btn_primary.setVisible(True) + self._btn_update.setVisible(False) + self._btn_more.setEnabled(False) + return + + if defn.is_engine and is_active: + bg, fg, badge_text = *_BADGE_ACTIVE, "Active Engine" + elif not installed: + bg, fg, badge_text = *_BADGE_NOT_INSTALLED, "Not Installed" + elif update_avail: + bg, fg, badge_text = *_BADGE_UPDATE_AVAIL, "Update Available" + else: + bg, fg, badge_text = *_BADGE_UP_TO_DATE, "Installed" + self._badge.setText(badge_text) + self._badge.setStyleSheet( + f"background-color: {bg}; color: {fg}; border-radius: 3px; " + f"padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;" + ) + + iv = self._status.installed_version or "-" + lv = self._status.latest_version or "checking..." + self._version_label.setText( + f"Installed: {iv}\nLatest: {lv}" if installed else f"Latest: {lv}" + ) + + if not installed: + self._btn_primary.setText("Install") + self._btn_primary.setStyleSheet(btn_style(_C_INSTALL)) + self._btn_primary.setEnabled(not self._busy) + self._btn_primary.setVisible(True) + elif defn.is_engine: + if is_active: + self._btn_primary.setText("Active") + self._btn_primary.setStyleSheet(btn_style(_C_DISABLED, disabled=True)) + self._btn_primary.setEnabled(False) + else: + self._btn_primary.setText("Set Active") + self._btn_primary.setStyleSheet(btn_style(_C_SET_ACTIVE)) + self._btn_primary.setEnabled(not self._busy) + self._btn_primary.setVisible(True) + elif defn.can_launch: + self._btn_primary.setText("Launch") + self._btn_primary.setStyleSheet(btn_style(_C_LAUNCH)) + self._btn_primary.setEnabled(not self._busy) + self._btn_primary.setVisible(True) + else: + self._btn_primary.setVisible(False) + + self._btn_update.setVisible(installed and update_avail and not self._busy) + if installed and update_avail: + self._btn_update.setStyleSheet(btn_style(_C_UPDATE)) + self._btn_more.setEnabled(not self._busy) + + def set_latest_version(self, tag: str) -> bool: + self._status.latest_version = tag + if self._status.installed and self._status.installed_version and tag != "unknown": + self._status.update_available = tag.lstrip("v") != self._status.installed_version.lstrip("v") + self._refresh_ui() + return self._status.update_available + + def set_active_engine(self, active_id: str): + self._active_engine_id = active_id + self._refresh_ui() + + def set_busy(self, busy: bool, label: Optional[str] = None): + self._busy = busy + self._busy_label = label if busy else None + self._refresh_ui() + + def mark_installed(self, version: str): + self._status.installed = True + self._status.installed_version = version + self._status.update_available = False + self._busy = False + self._busy_label = None + self._refresh_ui() + + def mark_uninstalled(self): + self._status.installed = False + self._status.installed_version = None + self._status.update_available = False + self._busy = False + self._busy_label = None + self._refresh_ui() + + def _prompt_uninstall(self, display_name: str): + dlg = QDialog(self.window()) + dlg.setWindowTitle("Uninstall Tool") + dlg.setWindowModality(Qt.ApplicationModal) + dlg.setAttribute(Qt.WA_DeleteOnClose) + dlg.setMinimumWidth(340) + dlg.setStyleSheet( + "QDialog { background-color: #232323; color: #e0e0e0; }" + "QLabel { color: #e0e0e0; font-size: 13px; background: transparent; border: none; }" + ) + layout = QVBoxLayout(dlg) + layout.setContentsMargins(20, 18, 20, 18) + layout.setSpacing(16) + msg = QLabel(f"Uninstall {display_name}?

This will delete the installed files.") + msg.setTextFormat(Qt.RichText) + msg.setWordWrap(True) + layout.addWidget(msg) + sep = QLabel() + sep.setFixedHeight(1) + sep.setStyleSheet("background: #3a3a3a; border: none;") + layout.addWidget(sep) + btn_row = QHBoxLayout() + btn_row.setSpacing(8) + btn_row.addStretch() + cancel_btn = QPushButton("Cancel") + cancel_btn.setFixedSize(90, 30) + cancel_btn.setStyleSheet(btn_style(_C_BACK)) + cancel_btn.setDefault(True) + cancel_btn.clicked.connect(dlg.reject) + btn_row.addWidget(cancel_btn) + uninstall_btn = QPushButton("Uninstall") + uninstall_btn.setFixedSize(90, 30) + uninstall_btn.setStyleSheet(btn_style("#8b2020")) + uninstall_btn.clicked.connect(dlg.accept) + btn_row.addWidget(uninstall_btn) + layout.addLayout(btn_row) + if dlg.exec() == QDialog.Accepted: + self.action_requested.emit(self._tool_id, "uninstall") + + def _on_primary(self): + defn = self._status.definition + if not self._status.installed: + self.action_requested.emit(self._tool_id, "install") + elif defn.is_engine: + try: + set_active_engine_id(self._tool_id) + self.engine_activated.emit(self._tool_id) + except Exception as e: + MessageService.warning(self, "Error", str(e)) + elif defn.can_launch: + if self._tool_id == "ttw_installer": + self.action_requested.emit(self._tool_id, "launch_jackify_ui") + else: + self._launch() + + def _launch(self): + binary = ToolRegistry().get_binary_path(self._tool_id) + if not binary: + MessageService.warning( + self, "Not Found", + f"No executable found for {self._status.definition.display_name}. Try reinstalling it." + ) + return + try: + subprocess.Popen( + [str(binary)], start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except Exception as e: + MessageService.warning(self, "Launch Failed", str(e)) + + def _on_more(self): + menu = QMenu(self) + menu.setStyleSheet( + "QMenu { background-color: #2a2a2a; color: #e0e0e0; border: 1px solid #444; }" + "QMenu::item:selected { background-color: #3a3a3a; }" + "QMenu::item:disabled { color: #555; }" + ) + defn = self._status.definition + downgrade_action = menu.addAction("Change Version") + downgrade_action.setEnabled(self._status.can_downgrade and not self._busy) + uninstall_action = menu.addAction("Uninstall") + uninstall_action.setEnabled(defn.can_uninstall and self._status.installed and not self._busy) + + chosen = menu.exec(self._btn_more.mapToGlobal(self._btn_more.rect().bottomLeft())) + if chosen == downgrade_action and downgrade_action.isEnabled(): + self.action_requested.emit(self._tool_id, "downgrade") + elif chosen == uninstall_action and uninstall_action.isEnabled(): + QTimer.singleShot(0, lambda: self._prompt_uninstall(defn.display_name)) diff --git a/jackify/frontends/gui/services/message_service.py b/jackify/frontends/gui/services/message_service.py index 259aff3..5f21229 100644 --- a/jackify/frontends/gui/services/message_service.py +++ b/jackify/frontends/gui/services/message_service.py @@ -24,36 +24,12 @@ class NonFocusMessageBox(QMessageBox): self._setup_no_focus_attributes(critical, safety_level) def _setup_no_focus_attributes(self, critical, safety_level): - """Configure the message box to not steal focus""" - # Set modality based on criticality and safety level if critical or safety_level == "high": self.setWindowModality(Qt.ApplicationModal) - elif safety_level == "medium": - self.setWindowModality(Qt.NonModal) else: self.setWindowModality(Qt.NonModal) - - # Prevent focus stealing + self.setAttribute(Qt.WA_ShowWithoutActivating, True) - self.setWindowFlags( - self.windowFlags() | - Qt.WindowStaysOnTopHint | - Qt.WindowDoesNotAcceptFocus - ) - - # Set focus policy to prevent taking focus - self.setFocusPolicy(Qt.NoFocus) - - # Make sure child widgets don't steal focus either - for child in self.findChildren(QWidget): - child.setFocusPolicy(Qt.NoFocus) - - def showEvent(self, event): - """Override to ensure no focus stealing on show""" - super().showEvent(event) - # Ensure we don't steal focus - self.activateWindow() - self.raise_() class SafeMessageBox(NonFocusMessageBox): diff --git a/jackify/frontends/gui/services/vnv_automation_controller.py b/jackify/frontends/gui/services/vnv_automation_controller.py index 13b8945..dcbfeb0 100644 --- a/jackify/frontends/gui/services/vnv_automation_controller.py +++ b/jackify/frontends/gui/services/vnv_automation_controller.py @@ -194,7 +194,10 @@ class VNVAutomationController(QObject): logger.warning("VNV non-premium: Nexus API query failed, cannot open download manager") try: import subprocess - subprocess.Popen(['xdg-open', 'https://www.nexusmods.com/newvegas/mods/62552?tab=files']) + import os + _strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"} + clean_env = {k: v for k, v in os.environ.items() if k not in _strip} + subprocess.Popen(['xdg-open', 'https://www.nexusmods.com/newvegas/mods/62552?tab=files'], env=clean_env, start_new_session=True) except Exception: pass from .message_service import MessageService diff --git a/jackify/frontends/gui/shared_theme.py b/jackify/frontends/gui/shared_theme.py index 3c7788d..315cb4b 100644 --- a/jackify/frontends/gui/shared_theme.py +++ b/jackify/frontends/gui/shared_theme.py @@ -8,8 +8,6 @@ DEBUG_BORDERS = False # Enable debug borders to visualize widget boundaries ASSETS_DIR = os.path.join(os.path.dirname(__file__), 'assets') LOGO_PATH = os.path.join(ASSETS_DIR, 'jackify_logo.png') DISCLAIMER_TEXT = ( - "Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, " - "without any warranty or guarantee of stability. By using Jackify, you acknowledge that you do so at your own risk. " - "The developers are not responsible for any data loss, system issues, or other problems that may arise from its use. " - "Please back up your data and use caution." -) \ No newline at end of file + "Jackify is provided as-is. Back up your modlist and game data before making changes. " + "The developers are not responsible for data loss or other issues arising from its use." +) \ No newline at end of file diff --git a/jackify/frontends/gui/widgets/progress_indicator.py b/jackify/frontends/gui/widgets/progress_indicator.py index 5863cf3..2086fc7 100644 --- a/jackify/frontends/gui/widgets/progress_indicator.py +++ b/jackify/frontends/gui/widgets/progress_indicator.py @@ -104,6 +104,14 @@ class OverallProgressIndicator(QWidget): # Update status text display_text = progress.display_text from jackify.shared.progress_models import InstallationPhase, FileProgress + # CLF3 Status events populate progress.message with detail not captured by display_text. + # Use the message directly when display_text is just the bare phase label. + if ( + progress.message + and progress.message not in ("Processing...", "") + and display_text in (progress.phase_name, progress.phase.value.title() if progress.phase else "", "Processing...", "") + ): + display_text = progress.message if not display_text or display_text == "Processing...": if progress.phase == InstallationPhase.UNKNOWN: # Don't overwrite the banner with "Unknown" for unrecognized section headers; diff --git a/jackify/frontends/gui/widgets/summary_progress_widget.py b/jackify/frontends/gui/widgets/summary_progress_widget.py index 8887792..37840f2 100644 --- a/jackify/frontends/gui/widgets/summary_progress_widget.py +++ b/jackify/frontends/gui/widgets/summary_progress_widget.py @@ -1,13 +1,15 @@ """ -Summary progress widget for phase display (e.g. Installing 123/456). +Summary progress widget for phase display (e.g. Queuing Archives 123/456). """ -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel -from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QProgressBar, QSizePolicy +from PySide6.QtCore import Qt, QTimer + +from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE class SummaryProgressWidget(QWidget): - """Widget showing summary progress for phases like Installing.""" + """Single-row summary widget matching FileProgressItem layout.""" def __init__(self, phase_name: str, current_step: int, max_steps: int, parent=None): super().__init__(parent) @@ -16,8 +18,8 @@ class SummaryProgressWidget(QWidget): self.max_steps = max_steps self._target_step = current_step self._target_max = max_steps - self._display_step = current_step - self._display_max = max_steps + self._display_step = float(current_step) + self._display_max = float(max_steps) self._interpolation_timer = QTimer(self) self._interpolation_timer.timeout.connect(self._interpolate_counter) self._interpolation_timer.setInterval(16) @@ -26,12 +28,43 @@ class SummaryProgressWidget(QWidget): self._update_display() def _setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(8, 8, 8, 8) - layout.setSpacing(6) + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 2, 4, 2) + layout.setSpacing(8) + + op_label = QLabel("»") + op_label.setFixedWidth(20) + op_label.setAlignment(Qt.AlignCenter) + op_label.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-weight: bold;") + layout.addWidget(op_label) + self.text_label = QLabel() - self.text_label.setStyleSheet("color: #ccc; font-size: 12px; font-weight: bold;") - layout.addWidget(self.text_label) + self.text_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.text_label.setStyleSheet("color: #ccc; font-size: 11px;") + layout.addWidget(self.text_label, 1) + + self.percent_label = QLabel() + self.percent_label.setFixedWidth(40) + self.percent_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.percent_label.setStyleSheet("color: #aaa; font-size: 11px;") + layout.addWidget(self.percent_label) + + self.progress_bar = QProgressBar() + self.progress_bar.setFixedHeight(12) + self.progress_bar.setFixedWidth(80) + self.progress_bar.setTextVisible(False) + self.progress_bar.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid #444; + border-radius: 2px; + background-color: #1a1a1a; + }} + QProgressBar::chunk {{ + background-color: {JACKIFY_COLOR_BLUE}; + border-radius: 1px; + }} + """) + layout.addWidget(self.progress_bar) def _interpolate_counter(self): step_diff = self._target_step - self._display_step @@ -53,12 +86,16 @@ class SummaryProgressWidget(QWidget): display_max = int(round(self._display_max)) if display_max > 0: - new_text = f"{self.phase_name} ({display_step}/{display_max})" + self.text_label.setText(f"{self.phase_name} ({display_step}/{display_max})") + pct = int(display_step / display_max * 100) + if self.progress_bar.maximum() == 0: + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(pct) + self.percent_label.setText(f"{pct}%") else: - new_text = f"{self.phase_name}" - - if self.text_label.text() != new_text: - self.text_label.setText(new_text) + self.text_label.setText(self.phase_name) + self.progress_bar.setValue(0) + self.percent_label.setText("") def update_progress(self, current_step: int, max_steps: int): self._target_step = current_step diff --git a/jackify/frontends/gui/widgets/unsupported_game_dialog.py b/jackify/frontends/gui/widgets/unsupported_game_dialog.py index 8449bdf..51c34f4 100644 --- a/jackify/frontends/gui/widgets/unsupported_game_dialog.py +++ b/jackify/frontends/gui/widgets/unsupported_game_dialog.py @@ -48,7 +48,7 @@ class UnsupportedGameDialog(QDialog): icon_label.setFont(QFont("Arial", 18, QFont.Weight.Bold)) icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) icon_label.setFixedSize(32, 32) - icon_label.setStyleSheet("color: #e67e22;") + icon_label.setStyleSheet("color: #f0c040;") title_layout.addWidget(icon_label) title_label = QLabel("VR Platform Notice" if self.vr_warning else "Game Support Notice") title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) @@ -73,7 +73,7 @@ class UnsupportedGameDialog(QDialog): message_text.setStyleSheet(""" QTextEdit { background-color: #23272e; - color: #f8f9fa; + color: #e0e0e0; border: 1px solid #444; border-radius: 6px; padding: 12px; @@ -191,10 +191,10 @@ class UnsupportedGameDialog(QDialog): self.setStyleSheet(""" QDialog { background-color: #23272e; - color: #f8f9fa; + color: #e0e0e0; } QLabel { - color: #f8f9fa; + color: #e0e0e0; } """) diff --git a/jackify/shared/errors.py b/jackify/shared/errors.py index bacacaa..f8cb76f 100644 --- a/jackify/shared/errors.py +++ b/jackify/shared/errors.py @@ -355,6 +355,34 @@ def manual_steps_incomplete() -> ConfigError: def cc_content_missing(filename: str = "") -> InstallError: detail = f"Missing file: {filename}" if filename else "" + is_curios = "curios" in filename.lower() + + if is_curios: + suggestion = ( + "You likely have the Steam-delivered version of Rare Curios, which has a different file hash " + "to the in-game version. The modlist requires the in-game version." + ) + solutions = [ + f"Delete both Curios files ({filename.rsplit('.', 1)[0]}.bsa and .esl) from your Skyrim/Data folder.", + "Launch Skyrim SE from Steam and go to Creations from the main menu.", + "Select Category, then Creation Club. Find 'Rare Curios' and download it.", + "Do NOT alt-tab while downloading - the download will stop if the game loses focus.", + "Do NOT run Steam 'Verify Integrity' after - it will replace the file with the wrong version again.", + "Rerun the Jackify installation.", + ] + else: + suggestion = ( + "Even if the file exists on disk, it may be the Steam-delivered version with a different hash. " + "Modlists require the in-game Creations version." + ) + solutions = [ + "Launch Skyrim SE/AE, go to Creations from the main menu, and select 'Download All'. " + "Wait for all downloads to complete, then retry the install.", + "Do not use 'Verify files' in Steam - it delivers a different version of the files that will fail the same check.", + "If a specific file is still flagged, find it in the Creations menu by name and re-download it individually.", + "Skyrim AE via Steam Family Sharing does not transfer DLC content - you must own AE directly.", + ] + return InstallError( title="Anniversary Edition Content Missing", message=( @@ -362,31 +390,35 @@ def cc_content_missing(filename: str = "") -> InstallError: "in your game installation." + (f" ({filename})" if filename else "") ), - suggestion="Open Vanilla Skyrim and allow it to download the required Anniversary Edition content.", - solutions=[ - "Open Vanilla Skyrim SE/AE and let it run until all Creation Club content has downloaded.", - "From the Skyrim main menu, go into Creations and select 'Download All'.", - "If specific files are still missing, search for and download them individually from the Creations menu.", - "If problems persist, uninstall and reinstall Skyrim, then launch once to trigger the AE download.", - "Note: Skyrim AE via Steam Family Sharing does not transfer DLC content - you must own AE directly.", - ], + suggestion=suggestion, + solutions=solutions, technical=format_technical_context(detail=detail) if detail else None, ) -def creation_kit_missing() -> InstallError: +def creation_kit_missing(game_type: str = None) -> InstallError: + if game_type and "fallout4" in game_type.lower(): + ck_name = "Fallout 4 Creation Kit" + steam_search = "Fallout 4: Creation Kit" + scripts_note = [] + else: + ck_name = "Skyrim Special Edition Creation Kit" + steam_search = "Skyrim Special Edition: Creation Kit" + scripts_note = [ + "When asked whether to unzip Scripts.zip, select NO - unzipping will cause the CK to crash.", + ] return InstallError( title="Creation Kit Files Missing", message=( - "This modlist requires the Skyrim Special Edition Creation Kit, " + f"This modlist requires the {ck_name}, " "but its files were not found in your game installation." ), suggestion="Install the Creation Kit from Steam and open it once to register its files.", solutions=[ - "In Steam, search for 'Skyrim Special Edition: Creation Kit' and install it.", + f"In Steam, search for '{steam_search}' and install it.", "Right-click it in Steam > Properties > Compatibility and set a Proton version.", "Click Play to launch the Creation Kit.", - "When asked whether to unzip Scripts.zip, select NO - unzipping will cause the CK to crash.", + *scripts_note, "Once the Creation Kit opens successfully, close it.", "Re-run the modlist install in Jackify - the required files will now be in place.", ], diff --git a/jackify/shared/logging.py b/jackify/shared/logging.py index a9da605..0f5621f 100644 --- a/jackify/shared/logging.py +++ b/jackify/shared/logging.py @@ -260,9 +260,9 @@ class LoggingHandler: debug_handler.setFormatter(file_formatter) root.addHandler(debug_handler) - # Console: errors only - warnings and below go to jackify.log + # Console: DEBUG in debug mode, ERROR otherwise console_handler = logging.StreamHandler() - console_handler.setLevel(logging.ERROR) + console_handler.setLevel(logging.DEBUG if debug_mode else logging.ERROR) console_handler.setFormatter(console_formatter) root.addHandler(console_handler) diff --git a/jackify/shared/progress_models.py b/jackify/shared/progress_models.py index 1273aeb..d611faf 100644 --- a/jackify/shared/progress_models.py +++ b/jackify/shared/progress_models.py @@ -359,6 +359,8 @@ class InstallationProgress: # Check for BSA building if 'bsa' in phase_lower or ('building' in phase_lower and self.phase == InstallationPhase.INSTALL): return "Building BSAs" + if ('extract' in phase_lower or 'queu' in phase_lower) and self.phase == InstallationPhase.INSTALL: + return "Queuing Archives" # For FINALIZE and INITIALIZATION phases, prefer phase_name over the generic label. # INITIALIZATION cycles through many short sections ("Configuring Installer", diff --git a/jackify/tools/vdf/__init__.py b/jackify/tools/vdf/__init__.py new file mode 100644 index 0000000..e569df1 --- /dev/null +++ b/jackify/tools/vdf/__init__.py @@ -0,0 +1,525 @@ +""" +Module for deserializing/serializing to and from VDF +""" +__version__ = "4.0" +__author__ = "Rossen Georgiev / Solstice Game Studios" + +import re +import struct +import sys +from binascii import crc32 +from collections.abc import Mapping +from io import BytesIO, StringIO + +from vdf.vdict import VDFDict + +BOMS = '\ufffe\ufeff' + +def strip_bom(line): + return line.lstrip(BOMS) + +# string escaping +_unescape_char_map = { + r"\n": "\n", + r"\t": "\t", + r"\v": "\v", + r"\b": "\b", + r"\r": "\r", + r"\f": "\f", + r"\a": "\a", + r"\\": "\\", + r"\?": "?", + r"\"": "\"", + r"\'": "\'", +} +_escape_char_map = {v: k for k, v in _unescape_char_map.items()} + +def _re_escape_match(m): + return _escape_char_map[m.group()] + +def _re_unescape_match(m): + return _unescape_char_map[m.group()] + +def _escape(text): + return re.sub(r"[\n\t\v\b\r\f\a\\\?\"']", _re_escape_match, text) + +def _unescape(text): + return re.sub(r"(\\n|\\t|\\v|\\b|\\r|\\f|\\a|\\\\|\\\?|\\\"|\\')", _re_unescape_match, text) + +# parsing and dumping for KV1 +def parse(fp, mapper=dict, merge_duplicate_keys=True, escaped=True): + """ + Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a VDF) + to a Python object. + + ``mapper`` specifies the Python object used after deserializetion. ``dict` is + used by default. Alternatively, ``collections.OrderedDict`` can be used if you + wish to preserve key order. Or any object that acts like a ``dict``. + + ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the + same key into one instead of overwriting. You can se this to ``False`` if you are + using ``VDFDict`` and need to preserve the duplicates. + """ + mapper = dict if mapper is None else mapper + if not issubclass(mapper, Mapping): + raise TypeError("Expected mapper to be subclass of dict, got %s" % type(mapper)) + if not hasattr(fp, 'readline'): + raise TypeError("Expected fp to be a file-like object supporting line iteration") + + stack = [mapper()] + expect_bracket = False + + re_keyvalue = re.compile(r'^("(?P(?:\\.|[^\\"])*)"|(?P#?[a-z0-9\-\_\\\?\+$%<>]+))' + r'([ \t]*(' + r'"(?P(?:\\.|[^\\"])*)(?P")?' + r'|(?P(?:(? ])+)' + r'|(?P{[ \t]*)(?P})?' + r'))?', + flags=re.I) + + for lineno, line in enumerate(fp, 1): + if lineno == 1: + line = strip_bom(line) + + line = line.lstrip() + + # skip empty and comment lines + if line == "" or line[0] == '/': + continue + + # one level deeper + if line[0] == "{": + expect_bracket = False + continue + + if expect_bracket: + raise SyntaxError("vdf.parse: expected openning bracket", + (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 1, line)) + + # one level back + if line[0] == "}": + if len(stack) > 1: + stack.pop() + continue + + raise SyntaxError("vdf.parse: one too many closing parenthasis", + (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) + + # parse keyvalue pairs + while True: + match = re_keyvalue.match(line) + + if not match: + try: + line += next(fp) + continue + except StopIteration: + raise SyntaxError("vdf.parse: unexpected EOF (open key quote?)", + (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) + + key = match.group('key') if match.group('qkey') is None else match.group('qkey') + val = match.group('qval') + if val is None: + val = match.group('val') + if val is not None: + val = val.rstrip() + if val == "": + val = None + + if escaped: + key = _unescape(key) + + # we have a key with value in parenthesis, so we make a new dict obj (level deeper) + if val is None: + if merge_duplicate_keys and key in stack[-1]: + _m = stack[-1][key] + # we've descended a level deeper, if value is str, we have to overwrite it to mapper + if not isinstance(_m, mapper): + _m = stack[-1][key] = mapper() + else: + _m = mapper() + stack[-1][key] = _m + + if match.group('eblock') is None: + # only expect a bracket if it's not already closed or on the same line + stack.append(_m) + if match.group('sblock') is None: + expect_bracket = True + + # we've matched a simple keyvalue pair, map it to the last dict obj in the stack + else: + # if the value is line consume one more line and try to match again, + # until we get the KeyValue pair + if match.group('vq_end') is None and match.group('qval') is not None: + try: + line += next(fp) + continue + except StopIteration: + raise SyntaxError("vdf.parse: unexpected EOF (open quote for value?)", + (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) + + stack[-1][key] = _unescape(val) if escaped else val + + # exit the loop + break + + if len(stack) != 1: + raise SyntaxError("vdf.parse: unclosed parenthasis or quotes (EOF)", + (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) + + return stack.pop() + + +def loads(s, **kwargs): + """ + Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a JSON + document) to a Python object. + """ + if not isinstance(s, str): + raise TypeError("Expected s to be a str, got %s" % type(s)) + + try: + fp = StringIO(s) + except TypeError: + fp = BytesIO(s) + + return parse(fp, **kwargs) + + +def load(fp, **kwargs): + """ + Deserialize ``fp`` (a ``.readline()``-supporting file-like object containing + a JSON document) to a Python object. + """ + return parse(fp, **kwargs) + + +def dumps(obj, pretty=False, escaped=True, acf=False): + """ + Serialize ``obj`` to a VDF formatted ``str``. + """ + if not isinstance(obj, Mapping): + raise TypeError("Expected data to be an instance of``dict``") + if not isinstance(pretty, bool): + raise TypeError("Expected pretty to be of type bool") + if not isinstance(escaped, bool): + raise TypeError("Expected escaped to be of type bool") + + return ''.join(_dump_gen(obj, pretty, escaped, acf)) + + +def dump(obj, fp, pretty=False, escaped=True, acf=False): + """ + Serialize ``obj`` as a VDF formatted stream to ``fp`` (a + ``.write()``-supporting file-like object). + """ + if not isinstance(obj, Mapping): + raise TypeError("Expected data to be an instance of``dict``") + if not hasattr(fp, 'write'): + raise TypeError("Expected fp to have write() method") + if not isinstance(pretty, bool): + raise TypeError("Expected pretty to be of type bool") + if not isinstance(escaped, bool): + raise TypeError("Expected escaped to be of type bool") + + for chunk in _dump_gen(obj, pretty, escaped, acf): + fp.write(chunk) + + +def _dump_gen(data, pretty=False, escaped=True, acf=False, level=0): + indent = "\t" + line_indent = "" + val_sep = " " + + if pretty: + line_indent = indent * level + + if acf: + val_sep = "\t\t" + + for key, value in data.items(): + if escaped and isinstance(key, str): + key = _escape(key) + + if isinstance(value, Mapping): + yield '{}"{}"\n{}{{\n'.format(line_indent, key, line_indent) + yield from _dump_gen(value, pretty, escaped, acf, level+1) + yield "%s}\n" % line_indent + else: + if escaped and isinstance(value, str): + value = _escape(value) + + yield '{}"{}"{}"{}"\n'.format(line_indent, key, val_sep, value) + + +# binary VDF +class BASE_INT(int): + def __repr__(self): + return "%s(%d)" % (self.__class__.__name__, self) + +class UINT_64(BASE_INT): + pass + +class INT_64(BASE_INT): + pass + +class POINTER(BASE_INT): + pass + +class COLOR(BASE_INT): + pass + +BIN_NONE = b'\x00' +BIN_STRING = b'\x01' +BIN_INT32 = b'\x02' +BIN_FLOAT32 = b'\x03' +BIN_POINTER = b'\x04' +BIN_WIDESTRING = b'\x05' +BIN_COLOR = b'\x06' +BIN_UINT64 = b'\x07' +BIN_END = b'\x08' +BIN_INT64 = b'\x0A' +BIN_END_ALT = b'\x0B' + +def binary_loads(b, mapper=dict, merge_duplicate_keys=True, alt_format=False, key_table=None, raise_on_remaining=True): + """ + Deserialize ``b`` (``bytes`` containing a VDF in "binary form") + to a Python object. + + ``mapper`` specifies the Python object used after deserializetion. ``dict` is + used by default. Alternatively, ``collections.OrderedDict`` can be used if you + wish to preserve key order. Or any object that acts like a ``dict``. + + ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the + same key into one instead of overwriting. You can se this to ``False`` if you are + using ``VDFDict`` and need to preserve the duplicates. + + ``key_table`` will be used to translate keys in binary VDF objects + which do not encode strings directly but instead store them in an out-of-band + table. Newer `appinfo.vdf` format stores this table the end of the file, + and it is needed to deserialize the binary VDF objects in that file. + """ + mapper = dict if mapper is None else mapper + if not isinstance(b, bytes): + raise TypeError("Expected s to be bytes, got %s" % type(b)) + + return binary_load(BytesIO(b), mapper, merge_duplicate_keys, alt_format, key_table, raise_on_remaining) + +def binary_load(fp, mapper=dict, merge_duplicate_keys=True, alt_format=False, key_table=None, raise_on_remaining=False): + """ + Deserialize ``fp`` (a ``.read()``-supporting file-like object containing + binary VDF) to a Python object. + + ``mapper`` specifies the Python object used after deserializetion. ``dict` is + used by default. Alternatively, ``collections.OrderedDict`` can be used if you + wish to preserve key order. Or any object that acts like a ``dict``. + + ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the + same key into one instead of overwriting. You can se this to ``False`` if you are + using ``VDFDict`` and need to preserve the duplicates. + + ``key_table`` will be used to translate keys in binary VDF objects + which do not encode strings directly but instead store them in an out-of-band + table. Newer `appinfo.vdf` format stores this table the end of the file, + and it is needed to deserialize the binary VDF objects in that file. + """ + if not hasattr(fp, 'read') or not hasattr(fp, 'tell') or not hasattr(fp, 'seek'): + raise TypeError("Expected fp to be a file-like object with tell()/seek() and read() returning bytes") + if not issubclass(mapper, Mapping): + raise TypeError("Expected mapper to be subclass of dict, got %s" % type(mapper)) + + # helpers + int32 = struct.Struct(' 1: + stack.pop() + continue + break + + if key_table: + # If 'key_table' was provided, each key is an int32 value that + # needs to be mapped to an actual field name using a key table. + # Newer appinfo.vdf (V29+) stores this table at the end of the file. + index = int32.unpack(fp.read(int32.size))[0] + + key = key_table[index] + else: + key = read_string(fp) + + if t == BIN_NONE: + if merge_duplicate_keys and key in stack[-1]: + _m = stack[-1][key] + else: + _m = mapper() + stack[-1][key] = _m + stack.append(_m) + elif t == BIN_STRING: + stack[-1][key] = read_string(fp) + elif t == BIN_WIDESTRING: + stack[-1][key] = read_string(fp, wide=True) + elif t in (BIN_INT32, BIN_POINTER, BIN_COLOR): + val = int32.unpack(fp.read(int32.size))[0] + + if t == BIN_POINTER: + val = POINTER(val) + elif t == BIN_COLOR: + val = COLOR(val) + + stack[-1][key] = val + elif t == BIN_UINT64: + stack[-1][key] = UINT_64(uint64.unpack(fp.read(int64.size))[0]) + elif t == BIN_INT64: + stack[-1][key] = INT_64(int64.unpack(fp.read(int64.size))[0]) + elif t == BIN_FLOAT32: + stack[-1][key] = float32.unpack(fp.read(float32.size))[0] + else: + raise SyntaxError("Unknown data type at offset %d: %s" % (fp.tell() - 1, repr(t))) + + if len(stack) != 1: + raise SyntaxError("Reached EOF, but Binary VDF is incomplete") + if raise_on_remaining and fp.read(1) != b'': + fp.seek(-1, 1) + raise SyntaxError("Binary VDF ended at offset %d, but there is more data remaining" % (fp.tell() - 1)) + + return stack.pop() + +def binary_dumps(obj, alt_format=False): + """ + Serialize ``obj`` to a binary VDF formatted ``bytes``. + """ + buf = BytesIO() + binary_dump(obj, buf, alt_format) + return buf.getvalue() + +def binary_dump(obj, fp, alt_format=False): + """ + Serialize ``obj`` to a binary VDF formatted ``bytes`` and write it to ``fp`` filelike object + """ + if not isinstance(obj, Mapping): + raise TypeError("Expected obj to be type of Mapping") + if not hasattr(fp, 'write'): + raise TypeError("Expected fp to have write() method") + + for chunk in _binary_dump_gen(obj, alt_format=alt_format): + fp.write(chunk) + +def _binary_dump_gen(obj, level=0, alt_format=False): + if level == 0 and len(obj) == 0: + return + + int32 = struct.Struct(' 0: + for idx in range(start_idx, len(self.__omap)): + if self.__omap[idx][1] == skey: + oldkey = self.__omap[idx] + newkey = (dup_idx, skey) + super().__setitem__(newkey, self[oldkey]) + super().__delitem__(oldkey) + self.__omap[idx] = newkey + + dup_idx += 1 + tail_count -= 1 + if tail_count == 0: + break + + if self.__kcount[skey] == 0: + del self.__kcount[skey] + + return result + + def __iter__(self): + return iter(self.iterkeys()) + + def __contains__(self, key): + return super().__contains__(self._normalize_key(key)) + + def __eq__(self, other): + if isinstance(other, VDFDict): + return list(self.items()) == list(other.items()) + else: + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def clear(self): + super().clear() + self.__kcount.clear() + self.__omap = list() + + def get(self, key, *args): + return super().get(self._normalize_key(key), *args) + + def setdefault(self, key, default=None): + if key not in self: + self.__setitem__(key, default) + return self.__getitem__(key) + + def pop(self, key): + key = self._normalize_key(key) + value = self.__getitem__(key) + self.__delitem__(key) + return value + + def popitem(self): + if not self.__omap: + raise KeyError("VDFDict is empty") + key = self.__omap[-1] + return key[1], self.pop(key) + + def update(self, data=None, **kwargs): + if isinstance(data, dict): + data = data.items() + elif not isinstance(data, list): + raise TypeError("Expected data to be a list or dict, got %s" % type(data)) + + for key, value in data: + self.__setitem__(key, value) + + def iterkeys(self): + return (key[1] for key in self.__omap) + + def keys(self): + return _kView(self) + + def itervalues(self): + return (self[key] for key in self.__omap) + + def values(self): + return _vView(self) + + def iteritems(self): + return ((key[1], self[key]) for key in self.__omap) + + def items(self): + return _iView(self) + + def get_all_for(self, key): + """ Returns all values of the given key """ + if not isinstance(key, str): + raise TypeError("Key needs to be a string.") + return [self[(idx, key)] for idx in range(self.__kcount[key])] + + def remove_all_for(self, key): + """ Removes all items with the given key """ + if not isinstance(key, str): + raise TypeError("Key need to be a string.") + + for idx in range(self.__kcount[key]): + super().__delitem__((idx, key)) + + self.__omap = list(filter(lambda x: x[1] != key, self.__omap)) + + del self.__kcount[key] + + def has_duplicates(self): + """ + Returns ``True`` if the dict contains keys with duplicates. + Recurses through any all keys with value that is ``VDFDict``. + """ + for n in self.__kcount.values(): + if n != 1: + return True + + def dict_recurse(obj): + for v in obj.values(): + if isinstance(v, VDFDict) and v.has_duplicates(): + return True + elif isinstance(v, dict): + return dict_recurse(v) + return False + + return dict_recurse(self) diff --git a/jackify/tools/verify_install.py b/jackify/tools/verify_install.py new file mode 100755 index 0000000..21a325a --- /dev/null +++ b/jackify/tools/verify_install.py @@ -0,0 +1,1298 @@ +#!/usr/bin/env python3 +""" +Jackify Install Verifier: standalone diagnostic tool. + +Run with no arguments to auto-discover installed modlists from Steam shortcuts. +Or point directly at a prefix and modlist directory. + +Usage: + python3 verify_install.py # interactive selection + python3 verify_install.py # direct + python3 verify_install.py -g falloutnv + +No external dependencies required. vdf is bundled in tools/vdf/. +""" + +import argparse +import configparser +import os +import re +import struct +import subprocess +import sys +from pathlib import Path +from typing import List, Dict, Optional, Tuple + +sys.path.insert(0, str(Path(__file__).parent)) +try: + import vdf + HAS_VDF = True +except ImportError: + HAS_VDF = False + + +def _vdf_extract_library_paths(content: str) -> list: + """Extract 'path' values from text-format libraryfolders.vdf.""" + return re.findall(r'"path"\s+"([^"]+)"', content) + + +def _vdf_extract_compat_tool(content: str, appid: str) -> Optional[str]: + """Extract the CompatToolMapping name for a given AppID from text config.vdf.""" + pattern = ( + r'"CompatToolMapping"\s*\{.*?"' + re.escape(appid) + r'"\s*\{.*?"name"\s+"([^"]+)"' + ) + m = re.search(pattern, content, re.DOTALL | re.IGNORECASE) + return m.group(1) if m else None + + +def _vdf_parse_binary_shortcuts(data: bytes) -> dict: + """Minimal binary VDF parser sufficient for shortcuts.vdf.""" + pos = [0] + + def read_str(): + end = data.index(b'\x00', pos[0]) + s = data[pos[0]:end].decode('utf-8', errors='replace') + pos[0] = end + 1 + return s + + def read_i32(): + v = struct.unpack_from(' (reg section, key name, reg file) +# reg file is "user" (user.reg / HKCU) or "system" (system.reg / HKLM) +GAME_REGISTRY = { + "falloutnv": (r"Software\\Wow6432Node\\bethesda softworks\\falloutnv", "Installed Path", "system"), + "fallout3": (r"Software\\Wow6432Node\\bethesda softworks\\fallout3", "Installed Path", "system"), + "enderal": (r"Software\\Wow6432Node\\SureAI\\Enderal SE", "installed path", "system"), + "cp2077": (r"Software\\CD Projekt Red\\Cyberpunk 2077", "InstallFolder", "system"), + "bg3": (r"Software\\Larian Studios\\Baldur's Gate 3", "InstallDir", "system"), +} + +# winetricks components Jackify installs per game type (modlist_wine_ops.py) +_WT_DEFAULT = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"] +_WT_MODERN = _WT_DEFAULT + ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"] +_WT_LEGACY = _WT_DEFAULT + ["d3dx9_43", "d3dx9"] + +GAME_WINETRICKS = { + "skyrim": _WT_MODERN, "skyrimvr": _WT_MODERN, "fallout4": _WT_MODERN, + "fallout4vr": _WT_MODERN, "starfield": _WT_MODERN, "oblivion_remastered": _WT_MODERN, + "enderal": _WT_MODERN, "cp2077": _WT_MODERN, "bg3": _WT_MODERN, + "falloutnv": _WT_LEGACY, "fallout3": _WT_LEGACY, "oblivion": _WT_LEGACY, +} + +# Seeded files: game_type -> list of (relative_path, description, optional_content_check) +SEEDED_FILES = { + "skyrim": [ + ("users/steamuser/AppData/Local/Skyrim Special Edition/Plugins.txt", + "USVFS anchor (empty file)", None), + ("users/steamuser/Documents/My Games/Skyrim Special Edition/SkyrimPrefs.ini", + "CC popup suppression stub", "bDownloadCC"), + ], + "fallout4": [ + ("users/steamuser/AppData/Local/Fallout4/Plugins.txt", + "USVFS anchor (empty file)", None), + ], + "skyrimvr": [ + ("users/steamuser/AppData/Local/Skyrim VR/Plugins.txt", + "USVFS anchor (empty file)", None), + ("users/steamuser/Documents/My Games/Skyrim VR/SkyrimPrefs.ini", + "VR first-launch stub", "bLoadVRPlayroom"), + ], + "fallout4vr": [ + ("users/steamuser/AppData/Local/Fallout4VR/Plugins.txt", + "USVFS anchor (empty file)", None), + ], +} + + +# --------------------------------------------------------------------------- +# Result tracking +# --------------------------------------------------------------------------- + +class Results: + def __init__(self): + self.passes = [] + self.warnings = [] + self.failures = [] + # List of {"name": str, "method": "native"|"winetricks"|"unknown"} dicts + self.installed_components = [] + + def ok(self, msg): + self.passes.append(msg) + print(f" [PASS] {msg}") + + def warn(self, msg): + self.warnings.append(msg) + print(f" [WARN] {msg}") + + def fail(self, msg): + self.failures.append(msg) + print(f" [FAIL] {msg}") + + def summary(self): + total = len(self.passes) + len(self.warnings) + len(self.failures) + print(f"\n{'='*60}") + print(f"Results: {len(self.passes)} passed, {len(self.warnings)} warnings, {len(self.failures)} failures (of {total} checks)") + if self.failures: + print(f"\nFailures:") + for f in self.failures: + print(f" - {f}") + if self.warnings: + print(f"\nWarnings:") + for w in self.warnings: + print(f" - {w}") + return len(self.failures) == 0 + + +# --------------------------------------------------------------------------- +# Steam discovery +# --------------------------------------------------------------------------- + +STEAM_ROOTS = [ + Path.home() / ".steam" / "steam", + Path.home() / ".local" / "share" / "Steam", + Path.home() / ".steam" / "root", + Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam", + Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / ".local" / "share" / "Steam", +] + + +def _find_steam_roots() -> List[Path]: + seen = set() + results = [] + for r in STEAM_ROOTS: + if not r.is_dir(): + continue + resolved = r.resolve() + if resolved not in seen: + seen.add(resolved) + results.append(r) + return results + + +def _find_steam_library_paths() -> List[Path]: + """Parse libraryfolders.vdf from all known Steam roots.""" + libraries = set() + for root in _find_steam_roots(): + libraries.add(root) + vdf_path = root / "config" / "libraryfolders.vdf" + if not vdf_path.is_file(): + continue + try: + content = vdf_path.read_text(encoding="utf-8", errors="replace") + if HAS_VDF: + data = vdf.loads(content) + for _, lib_data in data.get("libraryfolders", {}).items(): + if isinstance(lib_data, dict) and "path" in lib_data: + p = Path(lib_data["path"]) + if p.is_dir(): + libraries.add(p) + else: + for path_str in _vdf_extract_library_paths(content): + p = Path(path_str) + if p.is_dir(): + libraries.add(p) + except Exception: + pass + return list(libraries) + + +def _find_compat_data(appid: str) -> Optional[Path]: + """Resolve a Steam AppID to its compatdata directory.""" + for lib in _find_steam_library_paths(): + p = lib / "steamapps" / "compatdata" / appid + if p.is_dir(): + return p + return None + + +def _find_shortcuts_vdf_paths() -> List[Path]: + """Find all shortcuts.vdf files across Steam userdata directories.""" + paths = [] + for root in _find_steam_roots(): + userdata = root / "userdata" + if not userdata.is_dir(): + continue + for user_dir in userdata.iterdir(): + if not user_dir.is_dir(): + continue + s = user_dir / "config" / "shortcuts.vdf" + if s.is_file(): + paths.append(s) + return paths + + +def _parse_shortcuts_vdf(path: Path) -> List[Dict]: + """Return list of shortcut dicts from a binary shortcuts.vdf.""" + try: + with open(path, "rb") as f: + raw = f.read() + if HAS_VDF: + import io + data = vdf.binary_load(io.BytesIO(raw)) + else: + data = _vdf_parse_binary_shortcuts(raw) + return list(data.get("shortcuts", {}).values()) + except Exception: + return [] + + +def _signed_to_unsigned(appid: int) -> int: + if appid < 0: + return appid + (2 ** 32) + return appid + + +def discover_installed_modlists() -> List[Dict]: + """ + Scan Steam shortcuts.vdf for non-Steam shortcuts pointing at ModOrganizer.exe. + Returns list of dicts: {name, appid (str), modlist_dir, pfx, game_type} + """ + results = [] + seen_appids = set() + + for vdf_path in _find_shortcuts_vdf_paths(): + for shortcut in _parse_shortcuts_vdf(vdf_path): + exe = shortcut.get("Exe", shortcut.get("exe", "")).strip('"') + if "ModOrganizer.exe" not in os.path.basename(exe): + continue + + name = shortcut.get("AppName", shortcut.get("appname", "Unknown")) + start_dir = shortcut.get("StartDir", shortcut.get("startdir", "")).strip('"') + raw_appid = shortcut.get("appid", shortcut.get("AppID", shortcut.get("appId"))) + + if raw_appid is None or not start_dir: + continue + + unsigned_appid = str(_signed_to_unsigned(int(raw_appid))) + if unsigned_appid in seen_appids: + continue + seen_appids.add(unsigned_appid) + + modlist_dir = Path(start_dir) + # MO2 is sometimes in a files/ subdir + if not (modlist_dir / "ModOrganizer.ini").exists(): + alt = modlist_dir / "files" + if (alt / "ModOrganizer.ini").exists(): + modlist_dir = alt + + compat_data = _find_compat_data(unsigned_appid) + pfx = (compat_data / "pfx") if compat_data else None + + game_type = detect_game_type(modlist_dir) if modlist_dir.is_dir() else "unknown" + + results.append({ + "name": name, + "appid": unsigned_appid, + "modlist_dir": modlist_dir, + "pfx": pfx, + "game_type": game_type, + }) + + return results + + +def select_modlist_interactive() -> Optional[Dict]: + """Prompt user to pick from discovered modlists. Returns selected entry or None.""" + print("Scanning Steam shortcuts for installed modlists...") + modlists = discover_installed_modlists() + + if not modlists: + print("No modlists found. Ensure Steam shortcuts exist for ModOrganizer.exe.") + return None + + print(f"\nFound {len(modlists)} modlist(s):\n") + for i, m in enumerate(modlists, 1): + pfx_status = "pfx found" if (m["pfx"] and m["pfx"].is_dir()) else "pfx NOT found" + print(f" {i}. {m['name']}") + print(f" Game: {m['game_type']}") + print(f" Dir: {m['modlist_dir']}") + print(f" AppID: {m['appid']} ({pfx_status})") + print() + + while True: + try: + choice = input(f"Select modlist [1-{len(modlists)}] or q to quit: ").strip() + if choice.lower() == "q": + return None + idx = int(choice) - 1 + if 0 <= idx < len(modlists): + return modlists[idx] + except (ValueError, EOFError): + pass + print(f"Enter a number between 1 and {len(modlists)}.") + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + +def detect_game_type(modlist_dir: Path) -> str: + """Detect game type from the gameName key in ModOrganizer.ini [General].""" + mo_ini = modlist_dir / "ModOrganizer.ini" + if not mo_ini.exists(): + mo_ini = modlist_dir / "files" / "ModOrganizer.ini" + + if mo_ini.exists(): + try: + for line in mo_ini.read_text(errors="replace").splitlines(): + stripped = line.strip().lower() + if stripped.startswith("gamename"): + parts = stripped.split("=", 1) + if len(parts) != 2: + continue + game_name = parts[1].strip() + for game_type, keywords in GAME_KEYWORDS.items(): + for kw in keywords: + if kw in game_name: + return game_type + except Exception: + pass + + # Fallback: match against the directory name only + dir_name = modlist_dir.name.lower() + for game_type, keywords in GAME_KEYWORDS.items(): + for kw in keywords: + if kw in dir_name: + return game_type + + return "unknown" + + +# --------------------------------------------------------------------------- +# Checks +# --------------------------------------------------------------------------- + +def check_prefix_structure(pfx: Path, r: Results): + """Verify basic prefix directory structure.""" + print("\n--- Prefix Structure ---") + drive_c = pfx / "drive_c" + if drive_c.is_dir(): + r.ok("drive_c exists") + else: + r.fail("drive_c missing") + return + + for subdir in ["windows/system32", "windows/syswow64", "users/steamuser"]: + p = drive_c / subdir + if p.is_dir(): + r.ok(f"{subdir} exists") + else: + r.fail(f"{subdir} missing") + + +def check_registry_base(pfx: Path, r: Results): + """Check universal registry entries.""" + print("\n--- Base Registry Entries ---") + user_reg = pfx / "user.reg" + system_reg = pfx / "system.reg" + + if not user_reg.exists(): + r.fail("user.reg missing") + return + r.ok("user.reg exists") + + if not system_reg.exists(): + r.fail("system.reg missing") + return + r.ok("system.reg exists") + + user_content = user_reg.read_text(errors="replace") + system_content = system_reg.read_text(errors="replace") + + # FontSmoothingType + if "FontSmoothingType" in user_content: + r.ok("FontSmoothingType set") + else: + r.warn("FontSmoothingType not found") + + # ShowDotFiles + if "ShowDotFiles" in user_content: + r.ok("ShowDotFiles set") + else: + r.warn("ShowDotFiles not found") + + + +def check_game_registry(pfx: Path, game_type: str, r: Results): + """Check game-specific registry entries.""" + if game_type not in GAME_REGISTRY: + return + + print(f"\n--- Game Registry ({game_type}) ---") + section_pattern, key_name, reg_file = GAME_REGISTRY[game_type] + reg_path = pfx / ("system.reg" if reg_file == "system" else "user.reg") + if not reg_path.exists(): + r.fail(f"{reg_path.name} missing, cannot check game registry") + return + + content = reg_path.read_text(errors="replace") + section_search = section_pattern.lower().replace("\\\\", "\\") + content_lower = content.lower().replace("\\\\", "\\") + + if section_search in content_lower: + r.ok(f"Game registry section found: {section_pattern}") + if key_name.lower() in content_lower: + r.ok(f"Registry key '{key_name}' present") + else: + r.fail(f"Registry key '{key_name}' not found in game section") + else: + r.fail(f"Game registry section missing: {section_pattern}") + + +def check_user_directories(pfx: Path, game_type: str, r: Results): + """Check My Games and AppData directories.""" + print(f"\n--- User Directories ({game_type}) ---") + drive_c = pfx / "drive_c" + + # My Games + if game_type in MY_GAMES_DIR: + my_games = drive_c / "users/steamuser/Documents/My Games" / MY_GAMES_DIR[game_type] + if my_games.is_dir(): + r.ok(f"My Games/{MY_GAMES_DIR[game_type]} exists") + else: + r.fail(f"My Games/{MY_GAMES_DIR[game_type]} missing") + + # AppData/Local + if game_type in APPDATA_LOCAL_DIRS: + for subdir in APPDATA_LOCAL_DIRS[game_type]: + appdata = drive_c / "users/steamuser/AppData/Local" / subdir + if appdata.is_dir(): + r.ok(f"AppData/Local/{subdir} exists") + else: + r.fail(f"AppData/Local/{subdir} missing") + + +def check_seeded_files(pfx: Path, game_type: str, r: Results): + """Check first-launch seeded files.""" + if game_type not in SEEDED_FILES: + return + + print(f"\n--- First-Launch Seeded Files ({game_type}) ---") + drive_c = pfx / "drive_c" + + for rel_path, description, content_check in SEEDED_FILES[game_type]: + full_path = drive_c / rel_path + if full_path.exists(): + r.ok(f"{description}: {rel_path}") + if content_check: + content = full_path.read_text(errors="replace") + if content_check in content: + r.ok(f" contains expected '{content_check}'") + else: + r.warn(f" missing expected content '{content_check}'") + else: + r.fail(f"{description} missing: {rel_path}") + + +def _find_protontricks() -> Optional[str]: + for candidate in ["protontricks", "flatpak run com.github.Matoking.protontricks"]: + try: + result = subprocess.run( + candidate.split() + ["--version"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + return candidate + except Exception: + pass + return None + + +def check_winetricks_components(pfx: Path, appid: str, game_type: str, r: Results): + """Verify winetricks components. + + Primary: reads pfx/winetricks.log (winetricks writes this itself during install, fast). + Fallback: protontricks list-installed (slow Flatpak cold-start, 120s timeout). + """ + print(f"\n--- Winetricks Components ({game_type}) ---") + expected = GAME_WINETRICKS.get(game_type, _WT_DEFAULT) + + installed_output = "" + source = "" + + # Fast path: winetricks.log is written by protontricks/winetricks after each install + log_path = pfx / "winetricks.log" + if log_path.exists(): + try: + installed_output = log_path.read_text(errors="replace").lower() + source = "winetricks.log" + except Exception as e: + r.warn(f"Could not read winetricks.log: {e}") + + # Slow fallback: protontricks list-installed + if not installed_output: + pt = _find_protontricks() + if not pt: + r.warn("winetricks.log missing and protontricks not found -- cannot verify components") + return + try: + result = subprocess.run( + pt.split() + ["--no-bwrap", appid, "list-installed"], + capture_output=True, text=True, timeout=120 + ) + if result.returncode != 0: + r.warn(f"protontricks list-installed failed (rc={result.returncode}): {result.stderr.strip()[:120]}") + return + installed_output = result.stdout.lower() + source = "protontricks list-installed" + except Exception as e: + r.warn(f"protontricks list-installed error: {e}") + return + + missing = [] + for component in expected: + base = component.split("=")[0].lower() + if base in installed_output or component.lower() in installed_output: + r.ok(f"{component}") + else: + missing.append(component) + r.fail(f"{component} not found (source: {source})") + + if not missing: + r.ok(f"All {len(expected)} expected components verified ({source})") + + # Report notable extras installed beyond the baseline (e.g. dotnet48 added for .NET Script Framework) + _extras = ["dotnet48", "dotnet9", "dotnet10", "dotnetdesktop9", "dotnetdesktop10"] + for extra in _extras: + if extra not in expected and extra in installed_output: + r.ok(f"{extra} (extra, installed for this modlist)") + + # Build the installed_components inventory for GUI display. + # Read jackify_components.json for native-installed components (has method + timestamp). + native_record = {} + jc_path = pfx / "jackify_components.json" + if jc_path.exists(): + try: + import json as _json + native_record = _json.loads(jc_path.read_text(errors="replace")) + except Exception: + pass + + # Parse winetricks.log (one component per line) for winetricks-installed components. + wt_components = set() + if source == "winetricks.log": + for line in installed_output.splitlines(): + name = line.strip() + if name: + wt_components.add(name) + + # Merge: native_record takes precedence (has richer metadata). + seen = set() + for name, meta in native_record.items(): + seen.add(name.lower()) + r.installed_components.append({"name": name, "method": meta.get("method", "native")}) + for name in sorted(wt_components): + if name not in seen: + seen.add(name) + r.installed_components.append({"name": name, "method": "winetricks"}) + + +_LEGACY_GAME_TYPES = {"falloutnv", "fallout3", "oblivion"} + + +def _is_nsf_prefix(pfx: Path) -> bool: + """Return True if this prefix has global mscoree=native in Wine DllOverrides. + + All Skyrim prefixes get a per-exe AppDefaults\\SkyrimSE.exe mscoree override — + that does not qualify. NSF/CSF prefixes additionally have it in the global + DllOverrides section, which is what this checks. + """ + import re + user_reg = pfx / 'user.reg' + if not user_reg.is_file(): + return False + try: + c = user_reg.read_text(errors='replace').lower() + return bool(re.search( + r'\[hkey_current_user\\software\\wine\\dlloverrides\][^\[]*"?\*mscoree"?\s*=\s*"native"', + c, re.DOTALL + )) + except Exception: + return False + + +def check_native_dotnet(pfx: Path, r: Results, game_type: str = ""): + """Check native dotnet40/48 installation — only meaningful for NSF/CSF prefixes. + + Non-NSF prefixes always have phantom NDP keys written by Wine Mono; checking + them produces false positives. Skip entirely unless global mscoree=native is set. + """ + if game_type in _LEGACY_GAME_TYPES: + return + if not _is_nsf_prefix(pfx): + return + + marker40 = pfx / 'drive_c' / 'windows' / 'dotnet40.installed.workaround' + sys_reg = pfx / 'system.reg' + ndp40_ok = False + if sys_reg.is_file(): + try: + c = sys_reg.read_text(errors='replace').lower() + ndp40_ok = 'net framework setup\\\\ndp\\\\v4\\\\full' in c and '"install"=dword:00000001' in c + except Exception: + pass + if marker40.is_file() or ndp40_ok: + source = "marker+NDP" if (marker40.is_file() and ndp40_ok) else ("marker" if marker40.is_file() else "NDP registry") + r.ok(f"dotnet40 (NDP seeded, verified via {source})") + else: + r.fail("dotnet40 not detected: workaround marker and NDP registry keys both absent") + + marker = pfx / 'drive_c' / 'windows' / 'dotnet48.installed.workaround' + ndp_ok = False + if sys_reg.is_file(): + try: + c = sys_reg.read_text(errors='replace').lower() + ndp_ok = 'net framework setup\\\\ndp\\\\v4\\\\full' in c and '"release"' in c + except Exception: + pass + if marker.is_file() or ndp_ok: + source = "marker+NDP" if (marker.is_file() and ndp_ok) else ("marker" if marker.is_file() else "NDP registry") + r.ok(f"dotnet48 (native, verified via {source})") + else: + r.warn("dotnet48 not detected (expected if modlist uses dotnet40-only stack)") + + +def check_game_symlink(pfx: Path, modlist_dir: Path, game_type: str, r: Results): + """Check the game symlink directory inside the Wine prefix.""" + print(f"\n--- Game Symlink ---") + common_dir = pfx / "drive_c/Program Files (x86)/Steam/steamapps/common" + if not common_dir.exists(): + r.warn("Game symlink directory not found in Wine prefix (may be normal for some game types)") + return + + links = [p for p in common_dir.iterdir() if p.is_symlink() or p.is_dir()] + if not links: + r.warn("No game directories found in fake Steam common path") + return + + r.ok(f"Game symlinks present: {len(links)} entr{'y' if len(links) == 1 else 'ies'}") + + +def check_modlist_dir(modlist_dir: Path, r: Results): + """Basic modlist directory checks.""" + print(f"\n--- Modlist Directory ---") + if not modlist_dir.is_dir(): + r.fail(f"Modlist directory does not exist: {modlist_dir}") + return + + mo_exe = None + for candidate in ["ModOrganizer.exe", "modorganizer.exe"]: + p = modlist_dir / candidate + if not p.exists(): + p = modlist_dir / "files" / candidate + if p.exists(): + mo_exe = p + break + + if mo_exe: + r.ok(f"ModOrganizer.exe found: {mo_exe.relative_to(modlist_dir)}") + else: + r.warn("ModOrganizer.exe not found in modlist directory") + + mo_ini = modlist_dir / "ModOrganizer.ini" + if not mo_ini.exists(): + mo_ini = modlist_dir / "files" / "ModOrganizer.ini" + if mo_ini.exists(): + r.ok(f"ModOrganizer.ini found") + else: + r.warn("ModOrganizer.ini not found") + + +def _resolve_wine_path(gp: str, pfx: Optional[Path] = None) -> Optional[Path]: + """ + Convert a Wine drive path (Z:\\... or D:\\...) to a real filesystem path. + Z: always maps to filesystem root. + D: is resolved via the dosdevices symlink in the prefix when available, + otherwise falls back to scanning /run/media/ for SD card mounts. + """ + unix = gp.replace("\\\\", "/").replace("\\", "/") + drive = unix[:2].lower() + rest = unix[2:].lstrip("/") + + if drive == "z:": + return Path("/" + rest) + + if drive == "d:": + # Prefer reading the dosdevices symlink - it is the definitive mapping + if pfx: + dosdev = pfx / "dosdevices" / "d:" + if dosdev.is_symlink(): + try: + sd_root = Path(os.readlink(dosdev)) + if not sd_root.is_absolute(): + sd_root = (dosdev.parent / sd_root).resolve() + return sd_root / rest + except Exception: + pass + + # Fallback: scan /run/media/ for mounted volumes containing the path + for media_root in [Path("/run/media/deck"), Path("/run/media")]: + if not media_root.is_dir(): + continue + try: + for mount in media_root.iterdir(): + if not mount.is_dir(): + continue + candidate = mount / rest + if candidate.exists(): + return candidate + # One level deeper (e.g. /run/media/deck/CardName/...) + for submount in mount.iterdir(): + if submount.is_dir(): + candidate = submount / rest + if candidate.exists(): + return candidate + except Exception: + continue + + return None + + +def check_modorganizer_ini(modlist_dir: Path, r: Results, pfx: Optional[Path] = None): + """Check ModOrganizer.ini for gamePath set by Jackify during configuration.""" + print("\n--- ModOrganizer.ini ---") + mo_ini = modlist_dir / "ModOrganizer.ini" + if not mo_ini.exists(): + mo_ini = modlist_dir / "files" / "ModOrganizer.ini" + if not mo_ini.exists(): + r.warn("ModOrganizer.ini not found") + return + + game_path_raw = None + for line in mo_ini.read_text(errors="replace").splitlines(): + if line.strip().lower().startswith("gamepath"): + parts = line.split("=", 1) + if len(parts) == 2: + game_path_raw = parts[1].strip() + break + + if not game_path_raw: + r.fail("gamePath not set in ModOrganizer.ini (configuration may not have run)") + return + + gp = game_path_raw + if gp.startswith("@ByteArray(") and gp.endswith(")"): + gp = gp[len("@ByteArray("):-1] + + p = _resolve_wine_path(gp, pfx) + if p is None: + r.warn(f"gamePath uses unrecognised drive letter: {gp}") + return + + if p.is_dir(): + r.ok(f"gamePath set and exists: {p}") + else: + drive = gp[:2].upper() + if drive == "D:": + r.fail(f"gamePath set but directory missing (SD card path - card may not be mounted): {p}") + else: + r.fail(f"gamePath set but directory missing: {p}") + + +def check_download_directory(modlist_dir: Path, r: Results, pfx: Optional[Path] = None): + """Check download_directory in ModOrganizer.ini is a valid Wine path, not a raw Linux path.""" + print("\n--- download_directory ---") + mo_ini = modlist_dir / "ModOrganizer.ini" + if not mo_ini.exists(): + mo_ini = modlist_dir / "files" / "ModOrganizer.ini" + if not mo_ini.exists(): + r.warn("ModOrganizer.ini not found, cannot check download_directory") + return + + raw_value = None + for line in mo_ini.read_text(errors="replace").splitlines(): + if line.strip().lower().startswith("download_directory"): + parts = line.split("=", 1) + if len(parts) == 2: + raw_value = parts[1].strip() + # Take the last occurrence - MO2 reads the last one in duplicate-section installs + if raw_value is None: + r.warn("download_directory not set in ModOrganizer.ini") + return + + # Raw Linux path means Bug 3 fix didn't run or engine wrote an un-normalised value + if raw_value.startswith("/"): + r.fail(f"download_directory is a raw Linux path (not normalised to Wine format): {raw_value}") + return + + drive = raw_value[:2].upper() + if drive not in ("Z:", "D:"): + r.fail(f"download_directory has unexpected format: {raw_value}") + return + + p = _resolve_wine_path(raw_value, pfx) + if p is None: + r.warn(f"download_directory drive could not be resolved: {raw_value}") + return + + if p.is_dir(): + r.ok(f"download_directory set and exists: {p}") + elif drive == "D:": + r.fail(f"download_directory missing (SD card path - card may not be mounted): {p}") + else: + r.fail(f"download_directory set but directory does not exist: {p}") + + +def check_proton_version(appid: str, r: Results): + """Check the Proton compatibility tool set for this AppID in config.vdf.""" + print("\n--- Proton Version ---") + if not appid: + r.warn("AppID unknown, cannot check Proton version") + return + steam_roots = _find_steam_roots() + config_vdf = next((root / "config" / "config.vdf" for root in steam_roots if (root / "config" / "config.vdf").exists()), None) + if not config_vdf: + r.warn("config.vdf not found") + return + + try: + content = config_vdf.read_text(encoding="utf-8", errors="replace") + if HAS_VDF: + data = vdf.loads(content) + ctm = data["InstallConfigStore"]["Software"]["Valve"]["Steam"].get("CompatToolMapping", {}) + entry = ctm.get(appid) + name = entry.get("name", "") if entry else None + else: + name = _vdf_extract_compat_tool(content, appid) + if name: + r.ok(f"Proton version: {name}") + elif name is not None: + r.warn("Proton entry exists but tool name is empty") + else: + r.fail(f"No Proton version set for AppID {appid}") + except Exception as e: + r.warn(f"Could not read config.vdf: {e}") + + +def check_shortcut_launch_options(appid: str, game_type: str, r: Results): + """Read shortcuts.vdf and verify launch options for this AppID.""" + print("\n--- Launch Options ---") + if not appid: + r.warn("Cannot check launch options (AppID unknown)") + return + + for root in _find_steam_roots(): + userdata = root / "userdata" + if not userdata.is_dir(): + continue + for user_dir in userdata.iterdir(): + s = user_dir / "config" / "shortcuts.vdf" + if not s.is_file(): + continue + try: + with open(s, "rb") as f: + data = vdf.binary_load(f) + for _, sc in data.get("shortcuts", {}).items(): + raw_id = sc.get("appid", sc.get("AppID")) + if raw_id is None: + continue + unsigned = str(int(raw_id) + (2 ** 32) if int(raw_id) < 0 else int(raw_id)) + if unsigned != appid: + continue + opts = sc.get("LaunchOptions", sc.get("launchoptions", "")) + if opts: + r.ok(f"Launch options: {opts}") + else: + r.warn("Launch options empty") + if game_type == "cp2077": + if "WINEDLLOVERRIDES" in opts: + r.ok("CP2077 WINEDLLOVERRIDES present") + else: + r.fail("CP2077 missing required WINEDLLOVERRIDES in launch options") + return + except Exception: + continue + + r.warn(f"AppID {appid} not found in any shortcuts.vdf") + + +def check_nemesis_compat(modlist_dir: Path, r: Results, game_type: str = None): + """Check Nemesis symlink and workingDirectory fix were applied.""" + mods_dir = modlist_dir / "mods" + if not mods_dir.is_dir(): + return + + nemesis_engine_src = next( + (d / "Nemesis_Engine" for d in mods_dir.iterdir() + if (d / "Nemesis_Engine").is_dir()), + None + ) + if nemesis_engine_src is None: + return # modlist has no Nemesis, skip silently + + print("\n--- Nemesis Compatibility ---") + + stock_game_dir = next( + (modlist_dir / name for name in ("Stock Game", "StockGame", "Game Root", "Stock Folder") + if (modlist_dir / name).is_dir()), + None + ) + if stock_game_dir: + symlink = stock_game_dir / "Data" / "Nemesis_Engine" + if symlink.is_symlink(): + target = Path(os.readlink(symlink)) + if not target.is_absolute(): + target = (symlink.parent / target).resolve() + expected = nemesis_engine_src.resolve() + if target == expected: + r.ok(f"Nemesis_Engine symlink correct: {symlink}") + else: + r.warn(f"Nemesis_Engine symlink points to wrong target: {target} (expected {expected})") + elif symlink.exists(): + r.ok(f"Nemesis_Engine present at {symlink} (shipped by modlist, not a symlink)") + else: + # Nemesis is known broken on Linux (v0.6) regardless of symlink state. + # Downgraded to WARN - symlink missing indicates Step 16 bug but has no user-visible impact yet. + r.warn(f"Nemesis_Engine symlink missing at {symlink} (Nemesis known broken in v0.6 - track as post-release bug)") + else: + pass # no StockGame dir is a valid modlist structure; nothing to check + + mo_ini = modlist_dir / "ModOrganizer.ini" + if not mo_ini.is_file(): + return + content = mo_ini.read_text(errors="replace") + nemesis_indices = re.findall( + r'^(\d+)\\binary=.*Nemesis Unlimited Behavior Engine\.exe', + content, re.MULTILINE | re.IGNORECASE + ) + if nemesis_indices: + for idx in nemesis_indices: + wd_match = re.search(rf'^{re.escape(idx)}\\workingDirectory=(.*)$', content, re.MULTILINE) + if wd_match and wd_match.group(1).strip(): + r.fail(f"Nemesis workingDirectory not blank (entry {idx}): '{wd_match.group(1).strip()}'") + else: + r.ok(f"Nemesis workingDirectory blank (entry {idx})") + + +def check_nxmhandler_ini(modlist_dir: Path, r: Results): + """Check nxmhandler.ini has noregister=true to suppress MO2 NXM registration popup.""" + print("\n--- NXM Handler ---") + nxm_ini = modlist_dir / "nxmhandler.ini" + if not nxm_ini.exists(): + # MO2 creates it on first launch; pre-creation is done by Jackify during configure + r.warn("nxmhandler.ini not found (Jackify creates it during configure; may not exist before first configure run)") + return + + content = nxm_ini.read_text(errors="replace") + if re.search(r'(?im)^\s*noregister\s*=\s*true\s*$', content): + r.ok("nxmhandler.ini: noregister=true") + else: + r.fail("nxmhandler.ini exists but noregister=true not set (NXM popup will appear on MO2 launch)") + + +def check_ttw_installation(modlist_dir: Path, game_type: str, r: Results, modlist_name: str = ""): + """For TTW-compatible FNV modlists, check whether TaleOfTwoWastelands.esm is present.""" + if game_type != "falloutnv": + return + + try: + from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible + if not is_ttw_compatible(modlist_name): + return + except Exception: + pass + + print("\n--- TTW Installation ---") + + mods_dir = modlist_dir / "mods" + if mods_dir.is_dir(): + for esm in mods_dir.rglob("TaleOfTwoWastelands.esm"): + r.ok(f"TTW ESM found: {esm.relative_to(modlist_dir)}") + return + + r.warn("TTW ESM not found - TTW may not have been installed") + + +def check_tool_compat_config(pfx: Path, game_type: str, r: Results): + """Check whether Tool Compatibility Config has been applied to this prefix.""" + # Tool compat is not applied for these game types — nothing to check + _no_tool_compat = ("falloutnv", "fallout3", "enderal", "cp2077", "bg3", "skyrimvr", "fallout4vr") + if game_type in _no_tool_compat: + return + + print("\n--- Tool Compatibility Config ---") + user_reg = pfx / "user.reg" + system_reg = pfx / "system.reg" + if not user_reg.exists(): + r.warn("user.reg missing, cannot check tool compat config") + return + + content = user_reg.read_text(errors="replace") + + if "AppDefaults\\\\SSEEdit.exe" in content and "winxp" in content: + r.ok("xEdit WinXP compatibility applied") + else: + r.warn("xEdit WinXP compatibility not found (Tool Compat Config may not have been run)") + + if "Pandora Behaviour Engine" in content: + r.ok("Pandora window decoration fix applied") + else: + r.warn("Pandora fix not found") + + if "dwrite" in content and "native,builtin" in content: + r.ok("Global DLL overrides applied") + else: + r.warn("Global DLL overrides not found") + + # Synthesis: mscoree AppDefaults entry is Skyrim-specific + if game_type == "skyrim": + if "AppDefaults\\\\SkyrimSE.exe\\\\DllOverrides" in content and "mscoree" in content: + r.ok("Synthesis mscoree fix applied (SkyrimSE.exe AppDefaults)") + else: + r.warn("Synthesis mscoree AppDefaults entry not found") + + # OnlyUseLatestCLR is written to HKLM during the same Synthesis compat pass + sys_content = system_reg.read_text(errors="replace") if system_reg.exists() else "" + if ".NETFramework" in sys_content and "OnlyUseLatestCLR" in sys_content: + r.ok("OnlyUseLatestCLR set in HKLM") + else: + r.warn("OnlyUseLatestCLR not found in system.reg") + + # .NET 9 SDK is installed natively (not via winetricks) for Synthesis. + # NSF/CSF prefixes use global *mscoree=native and deliberately skip dotnet9 (win11 flip + # breaks NSF), so suppress the check when that override is present. + _nsf_prefix = '"*mscoree"="native"' in content + sdk_base = pfx / "drive_c" / "Program Files" / "dotnet" / "sdk" + if _nsf_prefix: + r.ok(".NET 9 SDK check skipped (NSF/CSF prefix - global mscoree=native detected)") + elif sdk_base.is_dir(): + net9_dirs = [d for d in sdk_base.iterdir() if d.is_dir() and d.name.startswith("9.")] + if net9_dirs: + r.ok(f".NET 9 SDK present: {net9_dirs[0].name}") + else: + installed = [d.name for d in sdk_base.iterdir() if d.is_dir()] + r.warn(f".NET 9 SDK not found under drive_c/Program Files/dotnet/sdk/ (found: {installed or 'none'})") + else: + r.warn(".NET 9 SDK not found (drive_c/Program Files/dotnet/sdk/ missing; run Configure Tool Compatibility)") + + +def check_steam_artwork(appid: str, r: Results): + """Check whether Steam grid artwork was applied for this AppID.""" + print("\n--- Steam Artwork ---") + if not appid: + r.warn("AppID unknown, cannot check artwork") + return + + expected = [ + (f"{appid}p.png", "portrait"), + (f"{appid}.png", "landscape"), + (f"{appid}_hero.png", "hero"), + (f"{appid}_logo.png", "logo"), + (f"{appid}_tenfoot.png", "tenfoot"), + ] + + found_any = False + for root in _find_steam_roots(): + userdata = root / "userdata" + if not userdata.is_dir(): + continue + for user_dir in userdata.iterdir(): + grid_dir = user_dir / "config" / "grid" + if not grid_dir.is_dir(): + continue + present = [label for fn, label in expected if (grid_dir / fn).is_file()] + if present: + found_any = True + r.ok(f"Artwork present ({user_dir.name}): {', '.join(present)}") + + if not found_any: + r.warn("No Steam grid artwork found for this AppID") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def run_verification(pfx: Path, modlist_dir: Path, game_type: str, appid: str = "", modlist_name: str = "") -> Results: + """Callable API for programmatic use. Returns Results; no stdout output.""" + import io + r = Results() + _orig = sys.stdout + sys.stdout = io.StringIO() + try: + check_prefix_structure(pfx, r) + check_registry_base(pfx, r) + check_game_registry(pfx, game_type, r) + check_user_directories(pfx, game_type, r) + check_seeded_files(pfx, game_type, r) + check_winetricks_components(pfx, appid, game_type, r) + check_native_dotnet(pfx, r, game_type) + check_game_symlink(pfx, modlist_dir, game_type, r) + check_modlist_dir(modlist_dir, r) + check_modorganizer_ini(modlist_dir, r, pfx=pfx) + check_download_directory(modlist_dir, r, pfx=pfx) + check_nxmhandler_ini(modlist_dir, r) + check_ttw_installation(modlist_dir, game_type, r, modlist_name) + check_nemesis_compat(modlist_dir, r, game_type) + check_proton_version(appid, r) + check_shortcut_launch_options(appid, game_type, r) + check_steam_artwork(appid, r) + check_tool_compat_config(pfx, game_type, r) + finally: + sys.stdout = _orig + return r + + +def _run_verification(pfx: Path, modlist_dir: Path, game_type: str, appid: str = ""): + print(f"\nJackify Install Verifier") + print(f"{'='*60}") + print(f"Prefix: {pfx}") + print(f"Modlist dir: {modlist_dir}") + print(f"Game type: {game_type}") + if appid: + print(f"AppID: {appid}") + + if game_type == "unknown": + print("\nWARNING: Could not detect game type. Use --game-type to specify.") + print("Continuing with generic checks only.\n") + + r = Results() + + check_prefix_structure(pfx, r) + check_registry_base(pfx, r) + check_game_registry(pfx, game_type, r) + check_user_directories(pfx, game_type, r) + check_seeded_files(pfx, game_type, r) + check_winetricks_components(pfx, appid, game_type, r) + check_native_dotnet(pfx, r) + check_game_symlink(pfx, modlist_dir, game_type, r) + check_modlist_dir(modlist_dir, r) + check_modorganizer_ini(modlist_dir, r, pfx=pfx) + check_download_directory(modlist_dir, r, pfx=pfx) + check_nxmhandler_ini(modlist_dir, r) + check_ttw_installation(modlist_dir, game_type, r) + check_nemesis_compat(modlist_dir, r, game_type) + check_proton_version(appid, r) + check_shortcut_launch_options(appid, game_type, r) + check_steam_artwork(appid, r) + check_tool_compat_config(pfx, game_type, r) + + return r.summary() + + +def main(): + parser = argparse.ArgumentParser( + description="Verify a Jackify modlist installation is correctly configured.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s # auto-discover from Steam shortcuts + %(prog)s /path/to/compatdata/12345/pfx /path/to/modlist + %(prog)s /path/to/pfx /path/to/modlist -g falloutnv + """, + ) + parser.add_argument("prefix", nargs="?", help="Path to the Proton prefix (pfx/ directory). Omit for interactive selection.") + parser.add_argument("modlist_dir", nargs="?", help="Path to the modlist installation directory.") + parser.add_argument("--game-type", "-g", help="Override auto-detected game type") + args = parser.parse_args() + + if args.prefix is None: + # Interactive mode: discover from Steam shortcuts + selected = select_modlist_interactive() + if selected is None: + sys.exit(1) + + modlist_dir = selected["modlist_dir"] + pfx = selected["pfx"] + appid = selected["appid"] + game_type = args.game_type.lower() if args.game_type else selected["game_type"] + + if pfx is None or not pfx.is_dir(): + print(f"\nError: prefix not found for AppID {appid}.") + print("The modlist may not have been launched yet (prefix created on first run).") + sys.exit(1) + else: + pfx = Path(args.prefix).expanduser().resolve() + modlist_dir = Path(args.modlist_dir).expanduser().resolve() if args.modlist_dir else None + + if not pfx.is_dir(): + print(f"Error: prefix path does not exist: {pfx}") + sys.exit(1) + if modlist_dir is None or not modlist_dir.is_dir(): + print(f"Error: modlist directory does not exist: {modlist_dir}") + sys.exit(1) + + game_type = args.game_type.lower() if args.game_type else detect_game_type(modlist_dir) + # Infer appid from the compatdata path if possible (e.g. .../compatdata/12345/pfx) + appid = pfx.parent.name if pfx.parent.name.isdigit() else "" + + passed = _run_verification(pfx, modlist_dir, game_type, appid) + sys.exit(0 if passed else 1) + + +if __name__ == "__main__": + main() diff --git a/jackify/tools/winetricks b/jackify/tools/winetricks index c2e7110..e0c9631 100755 --- a/jackify/tools/winetricks +++ b/jackify/tools/winetricks @@ -220,6 +220,7 @@ w_askpermission() pl*) w_die "Anulowano operację, opuszczanie." ;; pt*) w_die "Operação cancelada, saindo." ;; zh_CN*) w_die "操作已取消,正在退出。" ;; + zh_TW*|zh_HK*) w_die "操作已取消,正在退出。" ;; *) w_die "Operation cancelled, quitting." ;; esac fi @@ -431,10 +432,35 @@ _w_get_broken_messages() broken_no_version_known_no_mingw="此软件包(${W_PACKAGE})在 wine 未使用 mingw 构建时已损坏。更多信息请参见 ${bug_link}。如仍要尝试,请使用 --force。" # win64 broken messages - broken_good_version_known_win64="此软件包(${W_PACKAGE})在 64 位 wine-${_wine_version_stripped} 上存在问题。可以使用 WINEARCH=win32 创建的前缀,或升级 wine 至 >=${good_version} 来解决此问题。或者使用 --force 仍可尝试。更多信息请参见 ${bug_link}。" - broken_good_and_bad_version_known_win64="此软件包(${W_PACKAGE})在 64 位 wine-${_wine_version_stripped} 中已损坏,自 ${bad_version} 起出现该问题。请使用 WINEARCH=win32 创建的前缀,或升级 wine 至 >=${good_version} 来解决此问题。更多信息请参见 ${bug_link}。如仍要尝试,请使用 --force。" - broken_only_bad_version_known_win64="此软件包(${W_PACKAGE})在 64 位 wine-${_wine_version_stripped} 上已损坏,自 ${bad_version} 起出现该问题。请使用 WINEARCH=win32 创建的前缀作为解决方法。更多信息请参见 ${bug_link}。如仍要尝试,请使用 --force。" - broken_no_version_known_win64="此软件包(${W_PACKAGE})在 wine 未使用 mingw 构建时已损坏。更多信息请参见 ${bug_link}。如仍要尝试,请使用 --force。" + broken_good_version_known_win64="此软件包(${W_PACKAGE})在 64 位 wine-${_wine_version_stripped} 上存在问题。可以使用 WINEARCH=win32 创建的 Prefix,或升级 wine 至 >=${good_version} 来解决此问题。或者使用 --force 仍可尝试。更多信息请参见 ${bug_link}。" + broken_good_and_bad_version_known_win64="此软件包(${W_PACKAGE})在 64 位 wine-${_wine_version_stripped} 中已损坏,自 ${bad_version} 起出现该问题。请使用 WINEARCH=win32 创建的 Prefix,或升级 wine 至 >=${good_version} 来解决此问题。更多信息请参见 ${bug_link}。如仍要尝试,请使用 --force。" + broken_only_bad_version_known_win64="此软件包(${W_PACKAGE})在 64 位 wine-${_wine_version_stripped} 上已损坏,自 ${bad_version} 起出现该问题。请使用 WINEARCH=win32 创建的 Prefix 作为解决方法。更多信息请参见 ${bug_link}。如仍要尝试,请使用 --force。" + broken_no_version_known_win64="此软件包(${W_PACKAGE})在 64 位 wine-${_wine_version_stripped} 上存在问题。请使用以 WINEARCH=win32 创建的 Prefix。更多信息请参见 ${bug_link}。如仍要尝试,请使用 --force。" + ;; + zh_TW*|zh_HK*) + # default broken messages + broken_good_version_known_default="此軟體套件(${W_PACKAGE})在 wine-${_wine_version_stripped} 中已損壞,請升級到 >=${good_version},更多資訊請參閱 ${bug_link},若仍要嘗試,請使用 --force。" + broken_good_and_bad_version_known_default="此軟體套件(${W_PACKAGE})在 wine-${_wine_version_stripped} 中已損壞,自 ${bad_version} 起出現問題,請升級到 >=${good_version},更多資訊請參閱 ${bug_link},若仍要嘗試,請使用 --force。" + broken_only_bad_version_known_default="此軟體套件(${W_PACKAGE})在 wine-${_wine_version_stripped} 中已損壞,自 ${bad_version} 起出現問題。更多資訊請參閱 ${bug_link}。若仍要嘗試,請使用 --force。" + broken_no_version_known_default="此軟體套件(${W_PACKAGE})已損壞。更多資訊請參閱 ${bug_link}。若仍要嘗試,請使用 --force。" + + # mingw broken messages + broken_good_version_known_mingw="此軟體套件(${W_PACKAGE})在以 mingw 建置的 wine-${_wine_version_stripped} 中已損壞,請升級到 >=${good_version},或重新建置不含 mingw 的 wine。更多資訊請參閱 ${bug_link},若仍要嘗試,請使用 --force。" + broken_good_and_bad_version_known_mingw="此軟體套件(${W_PACKAGE})在 wine-${_wine_version_stripped} 中已損壞,自 ${bad_version} 起在以 mingw 建置的 wine 中出現問題。請升級到 >=${good_version},或重新建置不含 mingw 的 wine。更多資訊請參閱 ${bug_link},若仍要嘗試,請使用 --force。" + broken_only_bad_version_known_mingw="此軟體套件(${W_PACKAGE})在 wine-${_wine_version_stripped} 中已損壞。自 ${bad_version} 起,當 wine 以 mingw 建置時會出現此問題。更多資訊請參閱 ${bug_link}。若仍要嘗試,請使用 --force。" + broken_no_version_known_mingw="此軟體套件(${W_PACKAGE})在以 mingw 建置的 Wine 中有問題。更多資訊請參閱 ${bug_link}。若仍要嘗試,請使用 --force。" + + # no mingw broken messages + broken_good_version_known_no_mingw="此軟體套件(${W_PACKAGE})在未以 mingw 建置的 wine-${_wine_version_stripped} 中已損壞。請升級到 >=${good_version}。更多資訊請參閱 ${bug_link}。若仍要嘗試,請使用 --force。" + broken_good_and_bad_version_known_no_mingw="此軟體套件(${W_PACKAGE})在 wine-${_wine_version_stripped} 中已損壞。自 ${bad_version} 起,當 wine 未以 mingw 建置時會出現此問題。請升級到 >=${good_version}。更多資訊請參閱 ${bug_link}。若仍要嘗試,請使用 --force。" + broken_only_bad_version_known_no_mingw="此軟體套件(${W_PACKAGE})在 wine-${_wine_version_stripped} 中已損壞。自 ${bad_version} 起,當 wine 未以 mingw 建置時會出現此問題。更多資訊請參閱 ${bug_link}。若仍要嘗試,請使用 --force。" + broken_no_version_known_no_mingw="此軟體套件(${W_PACKAGE})在 wine 未以 mingw 建置時已損壞。更多資訊請參閱 ${bug_link}。若仍要嘗試,請使用 --force。" + + # win64 broken messages + broken_good_version_known_win64="此軟體套件(${W_PACKAGE})在 64 位元 wine-${_wine_version_stripped} 上有問題。可使用以 WINEARCH=win32 建立的 Prefix,或將 wine 升級至 >=${good_version} 來解決此問題。或者仍可使用 --force 嘗試。更多資訊請參閱 ${bug_link}。" + broken_good_and_bad_version_known_win64="此軟體套件(${W_PACKAGE})在 64 位元 wine-${_wine_version_stripped} 中已損壞,自 ${bad_version} 起出現此問題。請使用以 WINEARCH=win32 建立的 Prefix,或將 wine 升級至 >=${good_version} 來解決此問題。更多資訊請參閱 ${bug_link}。若仍要嘗試,請使用 --force。" + broken_only_bad_version_known_win64="此軟體套件(${W_PACKAGE})在 64 位元 wine-${_wine_version_stripped} 上已損壞,自 ${bad_version} 起出現此問題。請使用以 WINEARCH=win32 建立的 Prefix 作為解決方式。更多資訊請參閱 ${bug_link}。若仍要嘗試,請使用 --force。" + broken_no_version_known_win64="此軟體套件(${W_PACKAGE})在 64 位元 wine-${_wine_version_stripped} 上有問題。請使用以 WINEARCH=win32 建立的 Prefix。更多資訊請參閱 ${bug_link}。若仍要嘗試,請使用 --force。" ;; *) # default broken messages @@ -645,8 +671,8 @@ w_package_unsupported_win64() pl*) w_warn "Ten pakiet (${W_PACKAGE}) nie działa z 64-bitową instalacją. Musisz użyć prefiksu utworzonego z WINEARCH=win32." ;; pt*) w_warn "Este pacote (${W_PACKAGE}) não funciona em instalação de 64-bit. Você precisa usar um prefixo feito com WINEARCH=win32." ;; ru*) w_warn "Данный пакет не работает в 64-битном окружении. Используйте префикс, созданный с помощью WINEARCH=win32." ;; - zh_CN*) w_warn "(${W_PACKAGE}) 无法在 64 位环境下工作,您必须使用 WINEARCH=win32 创建的容器。" ;; - zh_TW*|zh_HK*) w_warn "(${W_PACKAGE}) 無法在64元下工作,只能將容器變數設定為 WINEARCH=win32 安装。" ;; + zh_CN*) w_warn "(${W_PACKAGE}) 无法在 64 位环境下工作,您必须使用以 WINEARCH=win32 创建的 Prefix。" ;; + zh_TW*|zh_HK*) w_warn "(${W_PACKAGE}) 無法在 64 位元環境下運作,您必須使用以 WINEARCH=win32 建立的 Prefix。" ;; *) w_warn "This package (${W_PACKAGE}) does not work on a 64-bit installation. You must use a prefix made with WINEARCH=win32." ;; esac _w_force_continue_check 32 @@ -662,8 +688,8 @@ w_package_warn_win64() pt*) w_warn "Este pacote (${W_PACKAGE}) talvez não funcione completamente em 64-bit. Em prefixo 32-bit talvez funcione melhor." ;; pl*) w_warn "Ten pakiet (${W_PACKAGE}) może nie działać poprawnie z 64-bitową instalacją. Prefiks 32-bitowy może działać lepiej." ;; ru*) w_warn "Данный пакет может быть не полностью работоспособным в 64-битном окружении. 32-битные префиксы могут работать лучше." ;; - zh_CN*) w_warn "(${W_PACKAGE}) 可能在64位环境下工作有问题,安装在32位环境可能会更好。" ;; - zh_TW*|zh_HK*) w_warn "(${W_PACKAGE}) 可能在64元環境下工作有問題,安装在32元環境可能會更好。" ;; + zh_CN*) w_warn "(${W_PACKAGE}) 在 64 位环境下可能无法完全正常工作,建议改用 32 位 Prefix。" ;; + zh_TW*|zh_HK*) w_warn "(${W_PACKAGE}) 在 64 位元環境下可能無法完全正常運作,建議改用 32 位元 Prefix。" ;; *) w_warn "This package (${W_PACKAGE}) may not fully work on a 64-bit installation. 32-bit prefixes may work better." ;; esac fi @@ -712,6 +738,7 @@ w_try() pt_abort="Nota: comando $* retornou o status ${status}. Cancelando." ru_abort="Важно: команда $* вернула статус ${status}. Прерывание." zh_CN_abort="注意:命令 $* 返回了状态码 ${status},操作中止。" + zh_TW_abort="注意:命令 $* 傳回狀態碼 ${status},操作中止。" if [ -n "${_w_ms_installer}" ]; then case ${status} in @@ -735,6 +762,7 @@ w_try() pt*) w_die "${pt_abort}" ;; ru*) w_die "${ru_abort}" ;; zh_CN*) w_die "${zh_CN_abort}" ;; + zh_TW*|zh_HK*) w_die "${zh_TW_abort}" ;; *) w_die "${en_abort}" ;; esac ;; @@ -1305,6 +1333,8 @@ winetricks_parse_wget_progress() '$| = 1; s/^.* +([0-9]+%) +([0-9,.]+[GMKBT]) +([0-9hms,.]+).*$/\1\n# Загрузка... \2 (\3)/' ;; zh_CN*) perl -p -e \ '$| = 1; s/^.* +([0-9]+%) +([0-9,.]+[GMKBT]) +([0-9hms,.]+).*$/\1\n# 正在下载…… \2 (\3)/' ;; + zh_TW*|zh_HK*) perl -p -e \ + '$| = 1; s/^.* +([0-9]+%) +([0-9,.]+[GMKBT]) +([0-9hms,.]+).*$/\1\n# 正在下載…… \2 (\3)/' ;; *) perl -p -e \ '$| = 1; s/^.* +([0-9]+%) +([0-9,.]+[GMKBT]) +([0-9hms,.]+).*$/\1\n# Downloading... \2 (\3)/' ;; esac @@ -1559,6 +1589,7 @@ w_download_to() pt*) w_warn "Checksum para ${_W_cache}/${_W_file} não confere, tentando novo download" ;; ru*) w_warn "Контрольная сумма файла ${_W_cache}/${_W_file} не совпадает, попытка повторной загрузки" ;; zh_CN*) w_warn "${_W_cache}/${_W_file} 的校验和不匹配,正在重新下载。" ;; + zh_TW*|zh_HK*) w_warn "${_W_cache}/${_W_file} 的總和檢查碼不符,正在重新下載。" ;; *) w_warn "Checksum for ${_W_cache}/${_W_file} did not match, retrying download" ;; esac mv -f "${_W_cache}/${_W_file}" "${_W_cache}/${_W_file}".bak @@ -1821,8 +1852,8 @@ w_download_manual_to() pt*) _W_dlmsg="Baixe o ${_W_file} de ${_W_url}, salve em ${W_CACHE}/${_W_packagename}, então tente executar novamente este script.";; ru*) _W_dlmsg="Пожалуйста, скачайте файл ${_W_file} по адресу ${_W_url}, и поместите его в ${W_CACHE}/${_W_packagename}, а затем запустите winetricks заново.";; uk*) _W_dlmsg="Будь ласка, звантажте ${_W_file} з ${_W_url}, розташуйте в ${W_CACHE}/${_W_packagename}, потім запустіть скрипт знову.";; - zh_CN*) _W_dlmsg="请从 ${_W_url} 下载 ${_W_file},并置放于 ${W_CACHE}/${_W_packagename}, 然后重新运行 winetricks.";; - zh_TW*|zh_HK*) _W_dlmsg="請從 ${_W_url} 下載 ${_W_file},并置放於 ${W_CACHE}/${_W_packagename}, 然后重新執行 winetricks.";; + zh_CN*) _W_dlmsg="请从 ${_W_url} 下载 ${_W_file},并将文件置于 ${W_CACHE}/${_W_packagename},然后重新运行 winetricks。";; + zh_TW*|zh_HK*) _W_dlmsg="請從 ${_W_url} 下載 ${_W_file},並將檔案置於 ${W_CACHE}/${_W_packagename},然後重新執行 winetricks。";; *) _W_dlmsg="Please download ${_W_file} from ${_W_url}, place it in ${W_CACHE}/${_W_packagename}, then re-run this script.";; esac @@ -3017,6 +3048,7 @@ winetricks_dl_warning() { ru*) _W_countrymsg="Скрипт определил, что ваш IP-адрес принадлежит России. Если во время загрузки файлов вы увидите ошибки несоответствия сертификата, перезапустите скрипт с опцией '--torify' или скачайте файлы вручную, например, используя VPN." ;; pl*) _W_countrymsg="Wykryto, że twój adres IP należy do Rosji. W wypadku problemów z pobieraniem, uruchom z parametrem '--torify' lub pobierz plik manualnie, np. z użyciem VPN." ;; zh_CN*) _W_countrymsg="系统检测到您的 IP 地址属于俄罗斯。如果您在下载时遇到证书错误,请使用选项 --torify 重新启动,或者通过 VPN 手动下载文件。" ;; + zh_TW*|zh_HK*) _W_countrymsg="系統偵測到您的 IP 位址位於俄羅斯。若下載時遇到憑證錯誤,請以選項 --torify 重新啟動,或透過 VPN 手動下載檔案。" ;; *) _W_countrymsg="Your IP address has been determined to belong to Russia. If you encounter a certificate error while downloading, please relaunch with the '--torify' option, or download files manually, for instance using VPN." ;; esac @@ -3088,7 +3120,7 @@ winetricks_latest_version_check() pt*) w_warn "Github offline? versão '${latest_version}' não parece uma versão válida" ;; ru*) w_warn "Отсутствует подключение к Github? Версия '${latest_version}' может быть неактуальной" ;; zh_CN*) w_warn "GitHub 无法访问?${latest_version} 似乎不是个有效的版本号。" ;; - zh_TW*|zh_HK*) w_warn "GitHub 宕機了?${latest_version} 似乎不是個有效的版本號。" ;; + zh_TW*|zh_HK*) w_warn "GitHub 無法連線?${latest_version} 似乎不是個有效的版本號。" ;; *) w_warn "Github down? version '${latest_version}' doesn't appear to be a valid version" ;; esac @@ -3121,11 +3153,11 @@ winetricks_latest_version_check() w_warn "Вы можете выполнить обновление с помощью менеджера пакетов, параметра --self-update или вручную." ;; zh_CN*) - w_warn "你正在使用 winetricks-${WINETRICKS_VERSION},最新版本是 winetricks-${latest_version}!" + w_warn "您正在使用 winetricks-${WINETRICKS_VERSION},最新版本为 winetricks-${latest_version}!" w_warn "建议通过发行版的包管理器、--self-update 或者手动方式进行更新。" ;; zh_TW*|zh_HK*) - w_warn "你正在使用 winetricks-${WINETRICKS_VERSION},最新版本是 winetricks-${latest_version}!" + w_warn "您正在使用 winetricks-${WINETRICKS_VERSION},最新版本為 winetricks-${latest_version}!" w_warn "建議通過發行版的套件管理員、--self-update 或者手動方式進行更新。" ;; *) @@ -3271,25 +3303,25 @@ winetricks_prefixmenu() _W_msg_unattended1="Увімкнути автоматичне встановлення" _W_msg_help="Переглянути довідку" ;; - zh_CN*) _W_msg_title="Winetricks - 请选择一个 Wine 容器" - _W_msg_body='您想要做些什么?' - _W_msg_apps='安装 Windows 应用' - _W_msg_benchmarks='安装基准测试软件' - _W_msg_default="选择默认的 Wine 容器" - _W_msg_mkprefix="创建新的 Wine 容器" + zh_CN*) _W_msg_title="Winetricks - 选择一个 Wineprefix" + _W_msg_body='您想做什么?' + _W_msg_apps='安装 Windows 应用程序' + _W_msg_benchmarks='安装基准测试程序' + _W_msg_default="选择默认的 Wineprefix" + _W_msg_mkprefix="创建新的 Wineprefix" _W_msg_unattended0="禁用静默安装" _W_msg_unattended1="启用静默安装" _W_msg_help="查看帮助" ;; - zh_TW*|zh_HK*) _W_msg_title="Winetricks - 取一 Wine 容器" - _W_msg_body='君欲何為?' - _W_msg_apps='安裝一個 Windows 應用' - _W_msg_benchmarks='安裝一個基准測試軟體' - _W_msg_default="選取預設的 Wine 容器" - _W_msg_mkprefix="建立新的 Wine 容器" - _W_msg_unattended0="禁用靜默安裝" + zh_TW*|zh_HK*) _W_msg_title="Winetricks - 選擇一個 Wineprefix" + _W_msg_body='您想做什麼?' + _W_msg_apps='安裝 Windows 應用程式' + _W_msg_benchmarks='安裝基準測試程式' + _W_msg_default="選取預設的 Wineprefix" + _W_msg_mkprefix="建立新的 Wineprefix" + _W_msg_unattended0="停用靜默安裝" _W_msg_unattended1="啟用靜默安裝" - _W_msg_help="檢視輔助說明" + _W_msg_help="查看說明" ;; de*) _W_msg_title="Winetricks - wineprefix auswählen" _W_msg_body='Was möchten Sie tun?' @@ -3370,8 +3402,8 @@ winetricks_prefixmenu() fi case ${LANG} in bg*) printf %s " FALSE prefix='${p}' 'Изберете ${_W_msg_name}' " ;; - zh_CN*) printf %s " FALSE prefix='${p}' '选择管理 ${_W_msg_name}' " ;; - zh_TW*|zh_HK*) printf %s " FALSE prefix='${p}' '選擇管理 ${_W_msg_name}' " ;; + zh_CN*) printf %s " FALSE prefix='${p}' '选择 ${_W_msg_name}' " ;; + zh_TW*|zh_HK*) printf %s " FALSE prefix='${p}' '選擇 ${_W_msg_name}' " ;; de*) printf %s " FALSE prefix='${p}' '${_W_msg_name} auswählen' " ;; pl*) printf %s " FALSE prefix='${p}' 'Wybierz ${_W_msg_name}' " ;; pt*) printf %s " FALSE prefix='${p}' 'Selecione ${_W_msg_name}' " ;; @@ -3439,10 +3471,14 @@ winetricks_mkprefixmenu() _W_msg_name="Nome" _W_msg_arch="Arquitetura" ;; - zh_CN*) _W_msg_title="Winetricks - 创建新的 wineprefix" + zh_CN*) _W_msg_title="Winetricks - 创建新的 Wineprefix" _W_msg_name="名称" _W_msg_arch="架构" ;; + zh_TW*|zh_HK*) _W_msg_title="Winetricks - 建立新的 Wineprefix" + _W_msg_name="名稱" + _W_msg_arch="架構" + ;; *) _W_msg_title="Winetricks - create new wineprefix" _W_msg_name="Name" _W_msg_arch="Architecture" @@ -3601,25 +3637,25 @@ winetricks_mainmenu() _W_msg_folder='Перегляд файлів' _W_msg_annihilate="Видалити УСІ ДАНІ ТА ПРОГРАМИ З ЦЬОГО WINEPREFIX" ;; - zh_CN*) _W_msg_title="Winetricks - 当前容器路径是 \"${WINEPREFIX}\"" - _W_msg_body='管理当前容器' + zh_CN*) _W_msg_title="Winetricks - 当前 Prefix 为 \"${WINEPREFIX}\"" + _W_msg_body='您想要对此 Wineprefix 做什么?' _W_msg_dlls="安装 Windows DLL 或组件" _W_msg_fonts='安装字体' _W_msg_settings='修改设置' _W_msg_winecfg='运行 Wine 配置程序' - _W_msg_regedit='运行注册表' + _W_msg_regedit='运行注册表编辑器' _W_msg_taskmgr='运行任务管理器' _W_msg_explorer='运行资源管理器' _W_msg_uninstaller='运行卸载程序' - _W_msg_winecmd='运行 Wine cmd' + _W_msg_winecmd='运行 Wine 命令提示符' _W_msg_wine_misc_exe='运行任意可执行文件 (.exe/.msi/.msu)' - _W_msg_shell='运行命令提示窗口 (作为调试)' - _W_msg_folder='浏览容器中的文件' - _W_msg_annihilate="删除容器中所有数据和应用程序" + _W_msg_shell='运行命令行(用于调试)' + _W_msg_folder='浏览文件' + _W_msg_annihilate="删除此 WINEPREFIX 中的所有数据和应用程序" ;; - zh_TW*|zh_HK*) _W_msg_title="Winetricks - 目前容器路徑是 \"${WINEPREFIX}\"" - _W_msg_body='管理目前容器' - _W_msg_dlls="安裝 Windows DLL 或套件" + zh_TW*|zh_HK*) _W_msg_title="Winetricks - 目前的 Prefix 為 \"${WINEPREFIX}\"" + _W_msg_body='您想要對此 Wineprefix 做什麼?' + _W_msg_dlls="安裝 Windows DLL 或元件" _W_msg_fonts='安裝字型' _W_msg_settings='修改設定' _W_msg_winecfg='執行 Wine 設定程式' @@ -3627,11 +3663,11 @@ winetricks_mainmenu() _W_msg_taskmgr='執行工作管理員' _W_msg_explorer='執行檔案總管' _W_msg_uninstaller='執行解除安裝程式' - _W_msg_winecmd='運行 Wine cmd' - _W_msg_wine_misc_exe='Run an arbitrary executable (.exe/.msi/.msu)' - _W_msg_shell='執行命令提示視窗 (作為偵錯)' - _W_msg_folder='瀏覽容器中的檔案' - _W_msg_annihilate="刪除容器中所有資料和應用程式" + _W_msg_winecmd='執行 Wine 命令提示字元' + _W_msg_wine_misc_exe='執行任意可執行檔 (.exe/.msi/.msu)' + _W_msg_shell='執行命令列(用於偵錯)' + _W_msg_folder='瀏覽檔案' + _W_msg_annihilate="刪除此 WINEPREFIX 中的所有資料和應用程式" ;; *) _W_msg_title="Winetricks - current prefix is \"${WINEPREFIX}\"" _W_msg_body='What would you like to do to this wineprefix?' @@ -3738,10 +3774,10 @@ winetricks_settings_menu() uk*) _W_msg_title="Winetricks - поточний prefix \"${WINEPREFIX}\"" _W_msg_body='Які налаштування Ви хочете змінити?' ;; - zh_CN*) _W_msg_title="Winetricks - 当前容器路径是 \"${WINEPREFIX}\"" + zh_CN*) _W_msg_title="Winetricks - 当前 Prefix 为 \"${WINEPREFIX}\"" _W_msg_body='您想要更改哪项设置?' ;; - zh_TW*|zh_HK*) _W_msg_title="Winetricks - 目前容器路徑是 \"${WINEPREFIX}\"" + zh_TW*|zh_HK*) _W_msg_title="Winetricks - 目前的 Prefix 為 \"${WINEPREFIX}\"" _W_msg_body='您想要變更哪項設定?' ;; *) _W_msg_title="Winetricks - current prefix is \"${WINEPREFIX}\"" @@ -3915,6 +3951,15 @@ winetricks_settings_menu() *) title="${title_zh_CN}";; esac ;; + zh_TW*|zh_HK*) + case "${title_zh_TW}" in + "") case "${title_zh_HK}" in + "") ;; + *) title="${title_zh_HK}";; + esac ;; + *) title="${title_zh_TW}";; + esac + ;; uk*) case "${title_uk}" in "") ;; @@ -3982,13 +4027,13 @@ winetricks_showmenu() _W_msg_body='Які пакунки Ви хочете встановити?' _W_cached="кешовано" ;; - zh_CN*) _W_msg_title="Winetricks - 当前容器路径是 \"${WINEPREFIX}\"" + zh_CN*) _W_msg_title="Winetricks - 当前 Prefix 为 \"${WINEPREFIX}\"" _W_msg_body='您想要安装什么应用程序?' _W_cached="已缓存" ;; - zh_TW*|zh_HK*) _W_msg_title="Winetricks - 目前容器路徑是 \"${WINEPREFIX}\"" + zh_TW*|zh_HK*) _W_msg_title="Winetricks - 目前的 Prefix 為 \"${WINEPREFIX}\"" _W_msg_body='您想要安裝什麼應用程式?' - _W_cached="已緩存" + _W_cached="已快取" ;; *) _W_msg_title="Winetricks - current prefix is \"${WINEPREFIX}\"" _W_msg_body='Which package(s) would you like to install?' @@ -4134,11 +4179,11 @@ winetricks_showmenu() --list \ --checklist \ --column '' \ - --column 包名 \ - --column 软件名 \ + --column 软件包名称 \ + --column 软件名称 \ --column 发行商 \ - --column 发行年 \ - --column 媒介 \ + --column 发行年份 \ + --column 媒体 \ --column 状态 \ --height ${WINETRICKS_MENU_HEIGHT} \ --width ${WINETRICKS_MENU_WIDTH} \ @@ -4150,11 +4195,11 @@ winetricks_showmenu() --list \ --checklist \ --column '' \ - --column 包名 \ - --column 軟體名 \ + --column 套件名稱 \ + --column 軟體名稱 \ --column 發行商 \ - --column 發行年 \ - --column 媒介 \ + --column 發行年份 \ + --column 媒體 \ --column 狀態 \ --height ${WINETRICKS_MENU_HEIGHT} \ --width ${WINETRICKS_MENU_WIDTH} \ @@ -4410,7 +4455,7 @@ winetricks_list_all() ru*) _W_cached="в кэше" ; _W_download="доступно для скачивания" ;; uk*) _W_cached="кешовано" ; _W_download="завантажуване" ;; zh_CN*) _W_cached="已缓存" ; _W_download="可下载" ;; - zh_TW*|zh_HK*) _W_cached="已緩存" ; _W_download="可下載" ;; + zh_TW*|zh_HK*) _W_cached="已快取" ; _W_download="可下載" ;; *) _W_cached="cached" ; _W_download="downloadable" ;; esac @@ -4728,7 +4773,8 @@ winetricks_set_wineprefix() fr*) w_warn "Vous utilisez un WINEPREFIX 64 bits. Notez que de nombreux verbes n'installent que des versions 32 bits des paquets. Si vous rencontrez des problèmes, veuillez refaire un test dans un WINEPREFIX 32 bits propre avant de signaler un bug." ;; ru*) w_warn "Вы используете 64-битный WINEPREFIX. Важно: многие ветки устанавливают только 32-битные версии пакетов. Если у вас возникли проблемы, пожалуйста, проверьте еще раз на чистом 32-битном WINEPREFIX до отправки отчета об ошибке." ;; pt*) w_warn "Você está usando um WINEPREFIX de 64-bit. Observe que muitos casos instalam apenas versões de pacotes de 32-bit. Se você encontrar problemas, teste novamente em um WINEPREFIX limpo de 32-bit antes de relatar um bug." ;; - zh_CN*) w_warn "您正在使用 64 位的 WINEPREFIX。请注意,许多脚本(verbs)只安装 32 位版本的软件包。如果遇到问题,请先在干净的 32 位 WINEPREFIX 中重新测试,然后再报告错误。" ;; + zh_CN*) w_warn "您正在使用 64 位 WINEPREFIX。请注意,许多脚本只会安装 32 位版本的软件包。如果遇到问题,请先在干净的 32 位 WINEPREFIX 中重新测试,再报告错误。" ;; + zh_TW*|zh_HK*) w_warn "您正在使用 64 位元 WINEPREFIX。請注意,許多指令稿只會安裝 32 位元版本的套件。如果遇到問題,請先在乾淨的 32 位元 WINEPREFIX 中重新測試,再回報錯誤。" ;; *) w_warn "You are using a 64-bit WINEPREFIX. Note that many verbs only install 32-bit versions of packages. If you encounter problems, please retest in a clean 32-bit WINEPREFIX before reporting a bug." ;; esac @@ -4842,7 +4888,8 @@ winetricks_annihilate_wineprefix() uk*) w_askpermission "Бажаєте видалити '${WINEPREFIX}'?" ;; pl*) w_askpermission "Czy na pewno chcesz usunąć prefiks ${WINEPREFIX} i wszystkie jego elementy?" ;; pt*) w_askpermission "Apagar ${WINEPREFIX}, Estes apps, ícones e ítens do menu?" ;; - zh_CN*) w_askpermission "要删除 ${WINEPREFIX} 及其应用程序、图标和菜单项?" ;; + zh_CN*) w_askpermission "要删除 ${WINEPREFIX} 及其应用程序、图标和菜单项吗?" ;; + zh_TW*|zh_HK*) w_askpermission "要刪除 ${WINEPREFIX} 及其應用程式、圖示和選單項目嗎?" ;; *) w_askpermission "Delete ${WINEPREFIX}, its apps, icons, and menu items?" ;; esac @@ -5256,7 +5303,7 @@ _EOF_ -f, --force 不检查软件包是否已安装     --gui 即使在命令行模式下,也显示 GUI 诊断     --gui=OPT 设置 GUI 引擎为 kdialog 或 zenity -    --isolate 将每个应用或游戏安装到独立的 WINEPREFIX 中 +    --isolate 将每个应用或游戏安装到独立的容器(WINEPREFIX)中     --self-update 更新此应用到最新版本     --update-rollback 回滚最近一次的自我更新     --no-clean 不删除临时目录(调试时有用) @@ -5281,9 +5328,49 @@ list-cached 列出已缓存且准备安装的脚本 list-download 列出会自动下载的脚本 list-manual-download 列出需要用户协助下载的脚本 list-installed 列出已安装的脚本 -arch=32|64 创建 32 位或 64 位 wineprefix,此选项必须在 prefix=foobar 之前指定,默认 wineprefix 不支持此选项 +arch=32|64 创建 32 位或 64 位 Wineprefix,此选项必须在 prefix=foobar 之前指定,默认 Wineprefix 不支持此选项 prefix=foobar 选择 WINEPREFIX 为 ${W_PREFIXES_ROOT}/foobar -annihilate 删除该 WINEPREFIX 内的所有数据和应用程序 +annihilate 删除该 WINEPREFIX 中的所有数据和应用程序 +_EOF_ + ;; + zh_TW*|zh_HK*) + cat <<_EOF_ +用法:$0 [選項] [命令|指令稿|指令稿路徑] ... +執行指定的指令稿。每個指令稿用於安裝應用程式或變更設定。 + +選項: +    --country=CC 將地區代碼設為 CC,且不偵測您的 IP 位址 +-f, --force 不檢查套件是否已安裝 +    --gui 即使在命令列模式下,也顯示 GUI 診斷資訊 +    --gui=OPT 將 GUI 引擎設為 kdialog 或 zenity +    --isolate 將每個應用程式或遊戲安裝到獨立的容器(WINEPREFIX)中 +    --self-update 將此程式更新到最新版本 +    --update-rollback 回復最近一次的自動更新 +    --no-clean 不刪除暫存目錄(偵錯時很有用) +    --optin 選擇向 Winetricks 維護者回報所使用的指令稿 +    --optout 選擇不向 Winetricks 維護者回報所使用的指令稿 +-q, --unattended 不詢問任何問題,直接自動安裝 +-t, --torify 若可用,透過 torify 執行下載 +    --verify 執行(自動化)GUI 指令稿測試(若有) +-v, --verbose 執行時回顯所有命令 +-h, --help 顯示此說明並結束 +-V, --version 顯示版本資訊並結束 + +命令: +list 列出分類 +list-all 列出所有分類及其指令稿 +apps list 列出 applications 分類下的指令稿 +benchmarks list 列出 benchmarks 分類下的指令稿 +dlls list 列出 dlls 分類下的指令稿 +fonts list 列出 fonts 分類下的指令稿 +settings list 列出 settings 分類下的指令稿 +list-cached 列出已快取且可直接安裝的指令稿 +list-download 列出會自動下載的指令稿 +list-manual-download 列出需要使用者協助下載的指令稿 +list-installed 列出已安裝的指令稿 +arch=32|64 建立 32 位元或 64 位元 Wineprefix,此選項必須在 prefix=foobar 之前指定,預設 Wineprefix 不支援此選項 +prefix=foobar 將 WINEPREFIX 設為 ${W_PREFIXES_ROOT}/foobar +annihilate 刪除此 WINEPREFIX 中的所有資料和應用程式 _EOF_ ;; *) @@ -9146,7 +9233,8 @@ load_dotnet40() # See https://bugs.winehq.org/show_bug.cgi?id=47277#c9 case "${LANG}" in C|en_US.UTF-8*) ;; - zh_CN*) w_warn "在尝试使用依赖 WPF 的应用程序时,你可能会遇到无限循环的问题。作为临时解决方法,请在运行应用程序时使用 LC_ALL=C。" + zh_CN*) w_warn "在尝试执行依赖 WPF 的应用程序时,您可能会遇到无限循环的问题。临时解决方法是在运行程序时使用 LC_ALL=C。" ;; + zh_TW*|zh_HK*) w_warn "在嘗試執行依賴 WPF 的應用程式時,您可能會遇到無限迴圈的問題。暫時的解決方式是在執行程式時使用 LC_ALL=C。" ;; # Based on the bug, there may be other locales that are affected. But in the absence of a full list # I don't think it's worth warning *every* non-en_US.UTF-8 user: # *) w_warn " @@ -9192,7 +9280,8 @@ load_dotnet40_kb2468871() # See https://bugs.winehq.org/show_bug.cgi?id=47277#c9 case "${LANG}" in C|en_US.UTF-8*) ;; - zh_CN*) w_warn "在尝试使用依赖 WPF 的应用程序时,你可能会遇到无限循环的问题。作为临时解决方法,请在运行应用程序时使用 LC_ALL=C。" + zh_CN*) w_warn "在尝试执行依赖 WPF 的应用程序时,您可能会遇到无限循环的问题。临时解决方法是在运行程序时使用 LC_ALL=C。" ;; + zh_TW*|zh_HK*) w_warn "在嘗試執行依賴 WPF 的應用程式時,您可能會遇到無限迴圈的問題。暫時的解決方式是在執行程式時使用 LC_ALL=C。" ;; # Based on the bug, there may be other locales that are affected. But in the absence of a full list # I don't think it's worth warning *every* non-en_US.UTF-8 user: # *) w_warn " @@ -9256,7 +9345,8 @@ load_dotnet45() # See https://bugs.winehq.org/show_bug.cgi?id=47277#c9 case "${LANG}" in C|en_US.UTF-8*) ;; - zh_CN*) w_warn "在尝试使用依赖 WPF 的应用程序时,你可能会遇到无限循环的问题。作为临时解决方法,请在运行应用程序时使用 LC_ALL=C。" + zh_CN*) w_warn "在尝试执行依赖 WPF 的应用程序时,您可能会遇到无限循环的问题。临时解决方法是在运行程序时使用 LC_ALL=C。" ;; + zh_TW*|zh_HK*) w_warn "在嘗試執行依賴 WPF 的應用程式時,您可能會遇到無限迴圈的問題。暫時的解決方式是在執行程式時使用 LC_ALL=C。" ;; # Based on the bug, there may be other locales that are affected. But in the absence of a full list # I don't think it's worth warning *every* non-en_US.UTF-8 user: # *) w_warn " @@ -9313,7 +9403,8 @@ load_dotnet452() # See https://bugs.winehq.org/show_bug.cgi?id=47277#c9 case "${LANG}" in C|en_US.UTF-8*) ;; - zh_CN*) w_warn "在尝试使用依赖 WPF 的应用程序时,你可能会遇到无限循环的问题。作为临时解决方法,请在运行应用程序时使用 LC_ALL=C。" + zh_CN*) w_warn "在尝试执行依赖 WPF 的应用程序时,您可能会遇到无限循环的问题。临时解决方法是在运行程序时使用 LC_ALL=C。" ;; + zh_TW*|zh_HK*) w_warn "在嘗試執行依賴 WPF 的應用程式時,您可能會遇到無限迴圈的問題。暫時的解決方式是在執行程式時使用 LC_ALL=C。" ;; # Based on the bug, there may be other locales that are affected. But in the absence of a full list # I don't think it's worth warning *every* non-en_US.UTF-8 user: # *) w_warn " @@ -9360,7 +9451,8 @@ load_dotnet46() # See https://bugs.winehq.org/show_bug.cgi?id=47277#c9 case "${LANG}" in C|en_US.UTF-8*) ;; - zh_CN*) w_warn "在尝试使用依赖 WPF 的应用程序时,你可能会遇到无限循环的问题。作为临时解决方法,请在运行应用程序时使用 LC_ALL=C。" + zh_CN*) w_warn "在尝试执行依赖 WPF 的应用程序时,您可能会遇到无限循环的问题。临时解决方法是在运行程序时使用 LC_ALL=C。" ;; + zh_TW*|zh_HK*) w_warn "在嘗試執行依賴 WPF 的應用程式時,您可能會遇到無限迴圈的問題。暫時的解決方式是在執行程式時使用 LC_ALL=C。" ;; # Based on the bug, there may be other locales that are affected. But in the absence of a full list # I don't think it's worth warning *every* non-en_US.UTF-8 user: # *) w_warn " @@ -9410,7 +9502,8 @@ load_dotnet461() # See https://bugs.winehq.org/show_bug.cgi?id=47277#c9 case "${LANG}" in C|en_US.UTF-8*) ;; - zh_CN*) w_warn "在尝试使用依赖 WPF 的应用程序时,你可能会遇到无限循环的问题。作为临时解决方法,请在运行应用程序时使用 LC_ALL=C。" + zh_CN*) w_warn "在尝试执行依赖 WPF 的应用程序时,您可能会遇到无限循环的问题。临时解决方法是在运行程序时使用 LC_ALL=C。" ;; + zh_TW*|zh_HK*) w_warn "在嘗試執行依賴 WPF 的應用程式時,您可能會遇到無限迴圈的問題。暫時的解決方式是在執行程式時使用 LC_ALL=C。" ;; # Based on the bug, there may be other locales that are affected. But in the absence of a full list # I don't think it's worth warning *every* non-en_US.UTF-8 user: # *) w_warn " @@ -9461,7 +9554,8 @@ load_dotnet462() # See https://bugs.winehq.org/show_bug.cgi?id=47277#c9 case "${LANG}" in C|en_US.UTF-8*) ;; - zh_CN*) w_warn "在尝试使用依赖 WPF 的应用程序时,你可能会遇到无限循环的问题。作为临时解决方法,请在运行应用程序时使用 LC_ALL=C。" + zh_CN*) w_warn "在尝试执行依赖 WPF 的应用程序时,您可能会遇到无限循环的问题。临时解决方法是在运行程序时使用 LC_ALL=C。" ;; + zh_TW*|zh_HK*) w_warn "在嘗試執行依賴 WPF 的應用程式時,您可能會遇到無限迴圈的問題。暫時的解決方式是在執行程式時使用 LC_ALL=C。" ;; # Based on the bug, there may be other locales that are affected. But in the absence of a full list # I don't think it's worth warning *every* non-en_US.UTF-8 user: # *) w_warn " @@ -9511,7 +9605,8 @@ load_dotnet471() # See https://bugs.winehq.org/show_bug.cgi?id=47277#c9 case "${LANG}" in C|en_US.UTF-8*) ;; - zh_CN*) w_warn "在尝试使用依赖 WPF 的应用程序时,你可能会遇到无限循环的问题。作为临时解决方法,请在运行应用程序时使用 LC_ALL=C。" + zh_CN*) w_warn "在尝试执行依赖 WPF 的应用程序时,您可能会遇到无限循环的问题。临时解决方法是在运行程序时使用 LC_ALL=C。" ;; + zh_TW*|zh_HK*) w_warn "在嘗試執行依賴 WPF 的應用程式時,您可能會遇到無限迴圈的問題。暫時的解決方式是在執行程式時使用 LC_ALL=C。" ;; # Based on the bug, there may be other locales that are affected. But in the absence of a full list # I don't think it's worth warning *every* non-en_US.UTF-8 user: # *) w_warn " @@ -9560,7 +9655,8 @@ load_dotnet472() # See https://bugs.winehq.org/show_bug.cgi?id=47277#c9 case "${LANG}" in C|en_US.UTF-8*) ;; - zh_CN*) w_warn "在尝试使用依赖 WPF 的应用程序时,你可能会遇到无限循环的问题。作为临时解决方法,请在运行应用程序时使用 LC_ALL=C。" + zh_CN*) w_warn "在尝试执行依赖 WPF 的应用程序时,您可能会遇到无限循环的问题。临时解决方法是在运行程序时使用 LC_ALL=C。" ;; + zh_TW*|zh_HK*) w_warn "在嘗試執行依賴 WPF 的應用程式時,您可能會遇到無限迴圈的問題。暫時的解決方式是在執行程式時使用 LC_ALL=C。" ;; # Based on the bug, there may be other locales that are affected. But in the absence of a full list # I don't think it's worth warning *every* non-en_US.UTF-8 user: # *) w_warn " @@ -9610,7 +9706,8 @@ load_dotnet48() # See https://bugs.winehq.org/show_bug.cgi?id=47277#c9 case "${LANG}" in C|en_US.UTF-8*) ;; - zh_CN*) w_warn "在尝试使用依赖 WPF 的应用程序时,你可能会遇到无限循环的问题。作为临时解决方法,请在运行应用程序时使用 LC_ALL=C。" + zh_CN*) w_warn "在尝试执行依赖 WPF 的应用程序时,您可能会遇到无限循环的问题。临时解决方法是在运行程序时使用 LC_ALL=C。" ;; + zh_TW*|zh_HK*) w_warn "在嘗試執行依賴 WPF 的應用程式時,您可能會遇到無限迴圈的問題。暫時的解決方式是在執行程式時使用 LC_ALL=C。" ;; # Based on the bug, there may be other locales that are affected. But in the absence of a full list # I don't think it's worth warning *every* non-en_US.UTF-8 user: # *) w_warn " @@ -12445,7 +12542,8 @@ load_quicktime72() bg*) w_warn "В настройките на Quicktime, включете Разширени / Безопасен режим (gdi), иначе видеоклиповете няма да се възпроизвеждат." ;; ru*) w_warn "В настройках Quicktime включите Дополнительно / Безопасный режим (только gdi), иначе видеофайлы не будут воспроизводиться." ;; pt*) w_warn "Nas preferências do Quicktime, marque Advanced / Safe Mode (gdi), ou os vídeos não irão reproduzir." ;; - zh_CN*) w_warn "在 QuickTime 的偏好设置中,勾选高级 / 安全模式(GDI),否则影片将无法播放。" ;; + zh_CN*) w_warn "在 QuickTime 的偏好设置中,勾选「高级 / 安全模式(GDI)」,否则视频将无法播放。" ;; + zh_TW*|zh_HK*) w_warn "在 QuickTime 的偏好設定中,勾選「進階 / 安全模式(GDI)」;否則影片將無法播放。" ;; *) w_warn "In Quicktime preferences, check Advanced / Safe Mode (gdi), or movies won't play." ;; esac if [ -z "${W_OPT_UNATTENDED}" ]; then @@ -17902,24 +18000,28 @@ w_metadata graphics=wayland settings \ title_uk="Встановити графічний драйвер Wayland" \ title_ru="Установить графический драйвер Wayland" \ title_zh_CN="将图形驱动设置为 Wayland" \ + title_zh_TW="將圖形驅動設為 Wayland" \ title="Set graphics driver to Wayland" w_metadata graphics=x11 settings \ title_bg="Задайте графичния драйвер X11" \ title_uk="Встановити графічний драйвер X11" \ title_ru="Установить графический драйвер X11" \ title_zh_CN="将图形驱动设置为 X11" \ + title_zh_TW="將圖形驅動設為 X11" \ title="Set graphics driver to X11" w_metadata graphics=mac settings \ title_bg="Задайте графичния драйвер Quartz (за macOS)" \ title_uk="Встановити графічний драйвер Quartz (для macOS)" \ title_ru="Установить графический драйвер Quartz (для macOS)" \ title_zh_CN="将图形驱动设置为 Quartz(针对 macOS)" \ + title_zh_TW="將圖形驅動設為 Quartz(適用於 macOS)" \ title="Set graphics driver to Quartz (for macOS)" w_metadata graphics=default settings \ title_bg="Задайте графичния драйвер по подразбиране" \ title_uk="Встановити графічний драйвер за замовчуванням" \ title_ru="Установить графический драйвер по умолчанию" \ title_zh_CN="将图形驱动恢复默认设置" \ + title_zh_TW="將圖形驅動還原為預設值" \ title="Set graphics driver to default" load_graphics() { @@ -19563,10 +19665,16 @@ winetricks_stats_init() declined="Надсилання звітності Winetricks вимкнено. Ви більше не отримуватиме це питання знову." ;; zh_CN*) - title="有关是否协助 Winetricks 开发的一次性询问。" - question="您是否愿意通过允许 winetricks 报告统计信息来协助 winetricks 的开发?您可以随时通过命令 'winetricks --optout' 关闭统计信息上报。" + title="协助 Winetricks 开发" + question="您是否愿意允许 Winetricks 上报统计信息,以协助 Winetricks 的开发?您可以随时通过命令 'winetricks --optout' 关闭统计信息上报。" thanks="谢谢!此问题将不会再次出现。请注意,您可随时使用命令 'winetricks --optout' 关闭统计信息上报。" - declined="好的,winetricks 将不会报告统计信息。此问题将不会再次出现。" + declined="好的,Winetricks 将不会上报统计信息。此问题将不会再次出现。" + ;; + zh_TW*|zh_HK*) + title="協助 Winetricks 開發" + question="您是否願意允許 Winetricks 回報使用統計,以協助 Winetricks 的開發?您可以隨時透過命令 'winetricks --optout' 關閉統計資訊回報。" + thanks="謝謝!此問題將不會再次出現。請注意,您可隨時使用命令 'winetricks --optout' 關閉統計資訊回報。" + declined="好的,Winetricks 將不會回報使用統計。此問題將不會再次出現。" ;; *) title="One-time question about helping Winetricks development"