From 2ff09a1448f040cb171063ff6f6589bec07f3a9e Mon Sep 17 00:00:00 2001 From: Omni Date: Mon, 20 Apr 2026 20:57:23 +0100 Subject: [PATCH] Release v0.6.0 --- CHANGELOG.md | 30 + README.md | 5 +- jackify/__init__.py | 2 +- .../modlist_operations_configuration_cli.py | 38 +- jackify/backend/handlers/config_handler.py | 2 +- .../backend/handlers/filesystem_handler.py | 210 +++++- .../handlers/filesystem_handler_steam.py | 2 +- jackify/backend/handlers/game_detector.py | 48 +- .../backend/handlers/modlist_configuration.py | 301 +++++++-- jackify/backend/handlers/modlist_detection.py | 47 +- jackify/backend/handlers/modlist_handler.py | 29 +- .../modlist_install_cli_configuration.py | 6 +- jackify/backend/handlers/modlist_wine_ops.py | 436 ++++++++++--- jackify/backend/handlers/path_handler_game.py | 3 +- jackify/backend/handlers/path_handler_mo2.py | 70 +- jackify/backend/handlers/progress_parser.py | 13 +- .../backend/handlers/progress_parser_phase.py | 4 +- .../backend/handlers/protontricks_commands.py | 4 +- .../backend/handlers/protontricks_prefix.py | 10 +- .../backend/handlers/protontricks_steam.py | 2 +- jackify/backend/handlers/shortcut_creation.py | 15 +- .../backend/handlers/shortcut_discovery.py | 2 +- .../handlers/shortcut_vdf_management.py | 4 +- jackify/backend/handlers/subprocess_utils.py | 2 +- .../backend/handlers/ttw_installer_backend.py | 2 +- jackify/backend/handlers/wabbajack_parser.py | 44 +- jackify/backend/handlers/wine_utils_config.py | 34 - jackify/backend/handlers/wine_utils_proton.py | 2 +- jackify/backend/handlers/winetricks_env.py | 15 +- jackify/backend/models/modlist.py | 6 +- .../services/automated_prefix_game_utils.py | 308 ++++----- .../services/automated_prefix_proton.py | 17 - .../services/automated_prefix_registry.py | 148 ++++- .../services/automated_prefix_shortcuts.py | 36 +- .../services/automated_prefix_workflow.py | 59 +- .../services/file_validator_service.py | 2 +- .../services/manual_download_manager.py | 4 +- .../manual_download_manager_runtime_mixin.py | 2 +- .../services/modlist_gallery_service.py | 10 +- jackify/backend/services/modlist_service.py | 16 +- .../services/modlist_service_installation.py | 6 +- .../backend/services/nexus_auth_service.py | 36 ++ .../backend/services/nexus_premium_service.py | 2 +- .../backend/services/steam_restart_service.py | 6 +- .../backend/services/steamgriddb_service.py | 181 ++++++ .../backend/services/tool_config_service.py | 600 ++++++++++++++++++ jackify/backend/services/tool_registry.py | 503 +++++++++++++++ jackify/backend/services/update_service.py | 95 ++- jackify/backend/utils/cc_content_detector.py | 2 +- jackify/backend/utils/engine_error_parser.py | 8 +- jackify/engine/Wabbajack.CLI.Builder.dll | Bin 25600 -> 25600 bytes jackify/engine/Wabbajack.Common.dll | Bin 205312 -> 205312 bytes jackify/engine/Wabbajack.Compiler.dll | Bin 160256 -> 160256 bytes jackify/engine/Wabbajack.Compression.BSA.dll | Bin 94720 -> 94720 bytes jackify/engine/Wabbajack.Compression.Zip.dll | Bin 18944 -> 18944 bytes jackify/engine/Wabbajack.Configuration.dll | Bin 4096 -> 4096 bytes jackify/engine/Wabbajack.DTOs.dll | Bin 142336 -> 142336 bytes .../engine/Wabbajack.Downloaders.Bethesda.dll | Bin 18432 -> 18432 bytes .../Wabbajack.Downloaders.Dispatcher.dll | Bin 29696 -> 29696 bytes .../engine/Wabbajack.Downloaders.GameFile.dll | Bin 16384 -> 16384 bytes .../Wabbajack.Downloaders.GoogleDrive.dll | Bin 17920 -> 17920 bytes jackify/engine/Wabbajack.Downloaders.Http.dll | Bin 15872 -> 15872 bytes ...ajack.Downloaders.IPS4OAuth2Downloader.dll | Bin 35328 -> 35328 bytes .../Wabbajack.Downloaders.Interfaces.dll | Bin 7168 -> 7168 bytes .../engine/Wabbajack.Downloaders.Manual.dll | Bin 8192 -> 8192 bytes .../Wabbajack.Downloaders.MediaFire.dll | Bin 15872 -> 15872 bytes jackify/engine/Wabbajack.Downloaders.Mega.dll | Bin 16384 -> 16384 bytes .../engine/Wabbajack.Downloaders.ModDB.dll | Bin 19456 -> 19456 bytes .../engine/Wabbajack.Downloaders.Nexus.dll | Bin 16896 -> 16896 bytes ...abbajack.Downloaders.VerificationCache.dll | Bin 13824 -> 13824 bytes .../Wabbajack.Downloaders.WabbajackCDN.dll | Bin 24576 -> 24576 bytes jackify/engine/Wabbajack.FileExtractor.dll | Bin 79872 -> 79872 bytes jackify/engine/Wabbajack.Hashing.PHash.dll | Bin 50176 -> 50176 bytes jackify/engine/Wabbajack.Hashing.xxHash64.dll | Bin 21504 -> 21504 bytes jackify/engine/Wabbajack.IO.Async.dll | Bin 15360 -> 15360 bytes jackify/engine/Wabbajack.Installer.dll | Bin 144384 -> 144384 bytes .../Wabbajack.Networking.BethesdaNet.dll | Bin 39936 -> 39936 bytes .../engine/Wabbajack.Networking.Discord.dll | Bin 14336 -> 14336 bytes .../engine/Wabbajack.Networking.GitHub.dll | Bin 21504 -> 21504 bytes .../Wabbajack.Networking.Http.Interfaces.dll | Bin 5632 -> 5632 bytes jackify/engine/Wabbajack.Networking.Http.dll | Bin 35840 -> 36864 bytes .../engine/Wabbajack.Networking.NexusApi.dll | Bin 82432 -> 83456 bytes ...abbajack.Networking.WabbajackClientApi.dll | Bin 77824 -> 77824 bytes jackify/engine/Wabbajack.Paths.IO.dll | Bin 34816 -> 34816 bytes jackify/engine/Wabbajack.Paths.dll | Bin 17408 -> 17408 bytes jackify/engine/Wabbajack.RateLimiter.dll | Bin 24576 -> 24576 bytes jackify/engine/Wabbajack.Server.Lib.dll | Bin 6656 -> 6656 bytes .../Wabbajack.Services.OSIntegrated.dll | Bin 55808 -> 55808 bytes jackify/engine/Wabbajack.VFS.Interfaces.dll | Bin 5120 -> 5120 bytes jackify/engine/Wabbajack.VFS.dll | Bin 64512 -> 64512 bytes jackify/engine/jackify-engine.deps.json | 452 ++++++------- jackify/engine/jackify-engine.dll | Bin 231936 -> 228864 bytes jackify/frontends/cli/__main__.py | 19 +- .../frontends/cli/commands/install_modlist.py | 16 +- jackify/frontends/cli/main.py | 2 - .../frontends/cli/menus/additional_menu.py | 73 ++- jackify/frontends/gui/__main__.py | 2 +- jackify/frontends/gui/dialogs/about_dialog.py | 10 +- .../gui/dialogs/enb_proton_dialog.py | 24 +- .../gui/dialogs/manual_download_dialog.py | 5 +- .../gui/dialogs/protontricks_error_dialog.py | 9 +- .../frontends/gui/dialogs/settings_dialog.py | 10 +- .../gui/dialogs/settings_dialog_tabs.py | 22 + .../frontends/gui/dialogs/success_dialog.py | 55 +- .../frontends/gui/dialogs/update_dialog.py | 11 +- jackify/frontends/gui/main.py | 47 +- .../gui/mixins/main_window_dialogs.py | 21 +- .../frontends/gui/mixins/main_window_ui.py | 26 +- .../gui/mixins/thread_lifecycle_mixin.py | 98 +++ .../frontends/gui/screens/additional_tasks.py | 7 + .../gui/screens/configure_existing_modlist.py | 43 +- .../configure_existing_modlist_workflow.py | 35 +- .../gui/screens/configure_new_modlist.py | 30 +- .../screens/configure_new_modlist_console.py | 6 +- .../screens/configure_new_modlist_dialogs.py | 31 +- .../screens/configure_new_modlist_workflow.py | 24 +- .../screens/configure_tool_config_screen.py | 442 +++++++++++++ .../gui/screens/install_mo2_screen.py | 14 +- .../frontends/gui/screens/install_modlist.py | 49 +- .../screens/install_modlist_configuration.py | 6 +- .../install_modlist_installer_thread.py | 11 +- .../gui/screens/install_modlist_nexus.py | 4 +- .../gui/screens/install_modlist_progress.py | 116 +--- .../gui/screens/install_modlist_selection.py | 24 +- .../gui/screens/install_modlist_ttw.py | 2 +- .../gui/screens/install_modlist_ui_setup.py | 2 +- .../install_modlist_workflow_execution.py | 37 +- jackify/frontends/gui/screens/install_ttw.py | 68 +- .../gui/screens/install_ttw_integration.py | 2 +- jackify/frontends/gui/screens/main_menu.py | 3 + .../frontends/gui/screens/modlist_gallery.py | 34 + .../gui/screens/screen_focus_reclaim.py | 2 +- .../gui/screens/third_party_tools.py | 478 ++++++++++++++ .../gui/screens/wabbajack_installer.py | 21 +- .../frontends/gui/services/message_service.py | 2 +- .../gui/services/vnv_automation_controller.py | 24 +- jackify/frontends/gui/utils.py | 54 +- .../gui/widgets/file_progress_item.py | 2 +- .../gui/widgets/file_progress_list.py | 10 +- .../gui/widgets/unsupported_game_dialog.py | 41 +- jackify/shared/errors.py | 26 +- jackify/shared/logging.py | 54 +- jackify/shared/paths.py | 9 +- jackify/tools/winetricks | 103 +-- 144 files changed, 4841 insertions(+), 1306 deletions(-) create mode 100644 jackify/backend/services/steamgriddb_service.py create mode 100644 jackify/backend/services/tool_config_service.py create mode 100644 jackify/backend/services/tool_registry.py create mode 100644 jackify/frontends/gui/mixins/thread_lifecycle_mixin.py create mode 100644 jackify/frontends/gui/screens/configure_tool_config_screen.py create mode 100644 jackify/frontends/gui/screens/third_party_tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db6993..44bc3e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Jackify Changelog +## v0.6 - Game Support Expansion, Modding Tool Support, Post-Install Quality +**Release Date:** 20/04/26 + +### New Game Support +- Additional Game Support - Post-Install automation for BG3, Skyrim VR, and Fallout 4 VR. +- Skyrim VR / Fallout 4 VR: if your modlist needs additional steps you know of, that Jackify does not yet handle, please open an issue on GitHub with your modlist name and the additional steps required. I cannot testing FO4VR directly as I dont own the game. + +### Modding Tool Support +- Initial compatibility settings for xEdit, Synthesis, and Pandora are applied automatically during install and configure. Re-apply any time via "Configure Tool Compatibility" in Additional Tasks. + +### Steam Shortcut Graphics +- Steam grid artwork now automatically applied to each shortcut, populating all five slots correctly (portrait, landscape, hero, logo, tenfoot). + +### First-Launch Reliability +- First Launch Fixes - Skyrim SE modlists should now launch cleanly first time. No more first-launch crash, incorrect AE/CC popup display, initial NXM prompt in MO2, character creation issues, and wrong initial save location. + +### Fixes +- Configuration no longer wipes game install paths. Registry writes are now targeted rather than full-prefix replacements. +- Fixed crashes on shutdown caused by force-killing background threads. + +### Logging +- Console output reduced to errors only. All informational output goes to the log file and Show Details panel. + +### Engine (0.5.4) +- Fixed Nexus sessions silently expiring after installs longer than ~1 hour. The engine now persists refreshed OAuth tokens so you stay logged in across long installs. +- Fixed large downloads hanging indefinitely if a Nexus CDN connection stalled mid-transfer. Downloads now recover automatically and resume from where they left off. +- Removed the disk space pre-flight check, which was incorrectly blocking installs for users with sufficient space. Out-of-disk conditions are still caught and reported if they actually occur. + +--- + ## v0.5.0.4 - Hotfix **Release Date:** 29/03/26 diff --git a/README.md b/README.md index 4ef68a3..3dedf02 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,12 @@ Jackify is a Linux application for installing and configuring Wabbajack modlists ## Requirements - Linux system (most modern distributions will work) -- Steam installed and configured +- 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 - See [Installing Additional Tools](https://github.com/Omni-guides/Jackify/wiki/Installing-Additional-Tools#installing-ge-proton) -- **Nexus Mods account** (Premium required for automated downloads) - - Non-Premium accounts are supported, but some downloads may require manual browser steps +- **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 - **FUSE2 compatibility (libfuse.so.2) is required for AppImage execution** - **IF YOU ARE USING an Ubuntu/Debian-based distro** (Ubuntu, Kubuntu, Linux Mint, Pop!_OS, Zorin OS, elementary OS, and others): Qt platform plugin library diff --git a/jackify/__init__.py b/jackify/__init__.py index 745a1f8..437a359 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.5.0.4" +__version__ = "0.6.0" diff --git a/jackify/backend/core/modlist_operations_configuration_cli.py b/jackify/backend/core/modlist_operations_configuration_cli.py index 096584e..f86e2f4 100644 --- a/jackify/backend/core/modlist_operations_configuration_cli.py +++ b/jackify/backend/core/modlist_operations_configuration_cli.py @@ -121,17 +121,16 @@ class ModlistOperationsConfigurationCLIMixin: if debug_mode: cmd.append('--debug') self.logger.info("Adding --debug flag to jackify-engine") - if self.context.get('skip_disk_check'): - cmd.append('--skip-disk-check') - self.logger.info("Adding --skip-disk-check flag to jackify-engine") - + writeback_path = str(auth_service.get_token_writeback_path()) original_env_values = { 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), '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 @@ -283,6 +282,7 @@ 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}.") @@ -595,6 +595,36 @@ class ModlistOperationsConfigurationCLIMixin: if update_existing_install and app_id: print(f"{COLOR_SUCCESS}Update mode Steam setup confirmed.{COLOR_RESET}") print(f"{COLOR_INFO}Reusing Steam AppID: {app_id}{COLOR_RESET}") + # Apply artwork and restart Steam -- skipped in update path since the full + # workflow is bypassed, but artwork and Steam state still need refreshing. + _game_type = self.context.get('detected_game') or self.context.get('special_game_type') + try: + from jackify.backend.handlers.modlist_handler import ModlistHandler + ModlistHandler().set_steam_grid_images(str(app_id), install_dir_str, game_type=_game_type) + except Exception as e: + self.logger.warning("Failed to apply Steam artwork in update mode: %s", e) + if _game_type == 'cp2077': + # CP2077 launch options may be absent on lists originally installed + # under v0.5 before CP2077 support was added. + try: + from jackify.backend.handlers.shortcut_handler import ShortcutHandler + from jackify.backend.handlers.config_handler import ConfigHandler + sh = ShortcutHandler( + config_handler=ConfigHandler(), + steamdeck=bool(self.system_info and self.system_info.is_steamdeck), + ) + sh.update_shortcut_launch_options( + shortcut_name, + mo2_exe_path, + 'WINEDLLOVERRIDES="version=n,b;winmm=n,b" %command%', + ) + except Exception as e: + self.logger.warning("Failed to update CP2077 launch options in update mode: %s", e) + try: + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + AutomatedPrefixService(self.system_info).restart_steam() + except Exception as e: + self.logger.warning("Failed to restart Steam in update mode: %s", e) else: print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}") if prefix_path: diff --git a/jackify/backend/handlers/config_handler.py b/jackify/backend/handlers/config_handler.py index a458fe7..00c297d 100644 --- a/jackify/backend/handlers/config_handler.py +++ b/jackify/backend/handlers/config_handler.py @@ -70,7 +70,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM "manual_download_concurrent_limit": 2, # Shared GUI/CLI default for manual download browser tabs "manual_download_watch_directory": None, # Optional override for manual-download watcher folder "window_width": None, # Saved window width (None = use dynamic sizing) - "window_height": None # Saved window height (None = use dynamic sizing) + "window_height": None, # Saved window height (None = use dynamic sizing) } # Load configuration if exists diff --git a/jackify/backend/handlers/filesystem_handler.py b/jackify/backend/handlers/filesystem_handler.py index 594a687..7ede31b 100644 --- a/jackify/backend/handlers/filesystem_handler.py +++ b/jackify/backend/handlers/filesystem_handler.py @@ -521,11 +521,13 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files # Game-specific Documents directory names (for both Linux home and Wine prefix) game_docs_dirs = { "skyrimse": "Skyrim Special Edition", + "skyrimvr": "Skyrim VR", "fallout4": "Fallout4", + "fallout4vr": "Fallout4VR", "falloutnv": "FalloutNV", "oblivion": "Oblivion", "enderal": "Enderal Special Edition", - "enderalse": "Enderal Special Edition" + "enderalse": "Enderal Special Edition", } game_dirs = { @@ -561,41 +563,193 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files os.makedirs(dir_path, exist_ok=True) self.logger.debug(f"Created game-specific directory: {dir_path}") - # CRITICAL: Create game-specific Documents directories in Wine prefix + # CP2077 and BG3 use AppData/Local only (no My Games) + appdata_only_dirs = { + "cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"), + "bg3": os.path.join("Larian Studios", "Baldur's Gate 3"), + } + + # CRITICAL: Create game-specific directories in Wine prefix # Required for USVFS to virtualize profile INIs on first launch - if game_name in game_docs_dirs: - docs_dir_name = game_docs_dirs[game_name] - - # Find compatdata path for this AppID - from ..handlers.path_handler import PathHandler - path_handler = PathHandler() - compatdata_path = path_handler.find_compat_data(appid) - - if compatdata_path: - # Create Documents/My Games/{GameName} in Wine prefix - wine_docs_path = os.path.join( - str(compatdata_path), - "pfx", - "drive_c", - "users", - "steamuser", - "Documents", - "My Games", - docs_dir_name + from ..handlers.path_handler import PathHandler + path_handler = PathHandler() + compatdata_path = path_handler.find_compat_data(appid) + + if compatdata_path: + prefix_user = os.path.join( + str(compatdata_path), "pfx", "drive_c", "users", "steamuser" + ) + + if game_name in appdata_only_dirs: + appdata_path = os.path.join( + prefix_user, "AppData", "Local", appdata_only_dirs[game_name] + ) + try: + os.makedirs(appdata_path, exist_ok=True) + self.logger.info(f"Created Wine prefix AppData/Local directory: {appdata_path}") + except Exception as e: + self.logger.warning(f"Could not create AppData/Local directory {appdata_path}: {e}") + + elif game_name in game_docs_dirs: + docs_dir_name = game_docs_dirs[game_name] + wine_docs_path = os.path.join( + prefix_user, "Documents", "My Games", docs_dir_name ) - try: os.makedirs(wine_docs_path, exist_ok=True) - self.logger.info(f"Created Wine prefix Documents directory for USVFS: {wine_docs_path}") - self.logger.debug(f"This allows USVFS to virtualize profile INI files on first launch") + self.logger.info(f"Created Wine prefix Documents directory: {wine_docs_path}") except Exception as e: self.logger.warning(f"Could not create Wine prefix Documents directory {wine_docs_path}: {e}") - # Don't fail completely - this is a first-launch optimization - else: - self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix Documents directory creation") - self.logger.debug("Wine prefix Documents directories will be created when game runs for first time") + + if game_name == "skyrimse": + self._seed_skyrim_first_launch_files(prefix_user, docs_dir_name) + elif game_name == "fallout4": + self._seed_fo4_first_launch_files(prefix_user, docs_dir_name) + elif game_name == "skyrimvr": + self._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name) + elif game_name == "fallout4vr": + self._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name) + else: + self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix directory creation") return True except Exception as e: self.logger.error(f"Error creating required directories: {e}") return False + + def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: + """ + Pre-seed files in the Wine prefix that Skyrim SE/AE needs on first launch. + + Two files must exist before first launch to avoid USVFS and engine issues: + + 1. AppData/Local/Skyrim Special Edition/Plugins.txt - empty anchor file. + USVFS builds its VFS tree at MO2 startup. If this path does not exist, + USVFS logs the directory as missing and skips adding Plugins.txt to the + initial tree. It then tries to reroute the file dynamically, but a mutex + deadlock (thread never releases the write mutex on first launch) blocks + the reroute. The game falls through to the real filesystem, finds no + Plugins.txt, and loads only base-game ESPs - causing a null form crash + for any SKSE plugin that expects modlist ESPs (e.g. BladeAndBlunt.dll). + On second launch the directory exists, USVFS initialises correctly, no crash. + Pre-seeding an empty file gives USVFS its anchor; content is irrelevant + because USVFS reroutes reads to the active MO2 profile's plugins.txt anyway. + + 2. Documents/My Games/Skyrim Special Edition/SkyrimPrefs.ini - minimal stub. + The CC/AE download prompt is triggered by bDownloadCC=0 (or absent) in + SkyrimPrefs.ini. This check fires before PrivateProfileRedirector (PPR) + hooks the Windows INI API, so the game reads the real prefix path directly, + not the MO2 profile version. A minimal stub with bDownloadCC=1 suppresses + the prompt. PPR redirects all subsequent reads to the active profile once + it loads, so this stub is never read again after early engine init. + Only created if the file does not already exist. + """ + # Fix 1: empty Plugins.txt anchor for USVFS + appdata_sse = os.path.join(prefix_user, "AppData", "Local", "Skyrim Special Edition") + plugins_txt = os.path.join(appdata_sse, "Plugins.txt") + try: + os.makedirs(appdata_sse, exist_ok=True) + if not os.path.exists(plugins_txt): + open(plugins_txt, 'w').close() + self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") + else: + self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") + except Exception as e: + self.logger.warning(f"Could not create Plugins.txt anchor: {e}") + + # Fix 2: minimal SkyrimPrefs.ini at real Documents path to suppress AE popup + skyrimprefs_path = os.path.join( + prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini" + ) + try: + if not os.path.exists(skyrimprefs_path): + with open(skyrimprefs_path, 'w', encoding='utf-8') as f: + f.write("[General]\nbDownloadCC=1\n") + self.logger.info(f"Created SkyrimPrefs.ini stub to suppress AE popup: {skyrimprefs_path}") + else: + self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}") + except Exception as e: + self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}") + + def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: + """ + Pre-seed files in the Wine prefix that Fallout 4 needs on first launch. + + 1. AppData/Local/Fallout4/Plugins.txt - empty anchor file for USVFS. + Same mutex deadlock mechanism as Skyrim SE - confirmed to apply to FO4. + + INI stub for CC popup suppression is intentionally omitted until the correct + key name in Fallout4Prefs.ini is confirmed via testing. + """ + appdata_fo4 = os.path.join(prefix_user, "AppData", "Local", docs_dir_name) + plugins_txt = os.path.join(appdata_fo4, "Plugins.txt") + try: + os.makedirs(appdata_fo4, exist_ok=True) + if not os.path.exists(plugins_txt): + open(plugins_txt, 'w').close() + self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") + else: + self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") + except Exception as e: + self.logger.warning(f"Could not create Plugins.txt anchor: {e}") + + def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: + """ + Pre-seed files in the Wine prefix that Skyrim VR needs on first launch. + + 1. AppData/Local/Skyrim VR/Plugins.txt - empty anchor file for USVFS. + Same mutex deadlock mechanism as Skyrim SE applies to VR. + + 2. Documents/My Games/Skyrim VR/SkyrimPrefs.ini - minimal stub with two keys: + - bDownloadCC=1: suppresses the AE/CC download prompt (same engine behaviour + as Skyrim SE; fires before PPR hooks the INI API). + - bLoadVRPlayroom=0: prevents the game loading the Bethesda VR playroom + tutorial on first launch. Without this, SkyrimVR skips the main menu and + drops the user into the playroom, bypassing the modlist's startup sequence. + """ + appdata_vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name) + plugins_txt = os.path.join(appdata_vr, "Plugins.txt") + try: + os.makedirs(appdata_vr, exist_ok=True) + if not os.path.exists(plugins_txt): + open(plugins_txt, 'w').close() + self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") + else: + self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") + except Exception as e: + self.logger.warning(f"Could not create Plugins.txt anchor: {e}") + + skyrimprefs_path = os.path.join( + prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini" + ) + try: + if not os.path.exists(skyrimprefs_path): + with open(skyrimprefs_path, 'w', encoding='utf-8') as f: + f.write("[General]\nbDownloadCC=1\nbLoadVRPlayroom=0\n") + self.logger.info(f"Created SkyrimPrefs.ini stub for VR first-launch: {skyrimprefs_path}") + else: + self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}") + except Exception as e: + self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}") + + def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: + """ + Pre-seed files in the Wine prefix that Fallout 4 VR needs on first launch. + + 1. AppData/Local/Fallout4VR/Plugins.txt - empty anchor file for USVFS. + Same mutex deadlock mechanism as Skyrim SE and FO4 applies to VR. + + INI stub is intentionally omitted - the correct key name in Fallout4VRPrefs.ini + has not been confirmed via testing. + """ + appdata_fo4vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name) + plugins_txt = os.path.join(appdata_fo4vr, "Plugins.txt") + try: + os.makedirs(appdata_fo4vr, exist_ok=True) + if not os.path.exists(plugins_txt): + open(plugins_txt, 'w').close() + self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") + else: + self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") + except Exception as e: + self.logger.warning(f"Could not create Plugins.txt anchor: {e}") diff --git a/jackify/backend/handlers/filesystem_handler_steam.py b/jackify/backend/handlers/filesystem_handler_steam.py index 5455736..d092f60 100644 --- a/jackify/backend/handlers/filesystem_handler_steam.py +++ b/jackify/backend/handlers/filesystem_handler_steam.py @@ -64,7 +64,7 @@ class FilesystemSteamMixin: default_path = Path.home() / ".steam/steam/steamapps/common" if default_path.is_dir(): - logger.warning(f"Using default Steam library path: {default_path}") + logger.info(f"Using default Steam library path: {default_path}") return default_path logger.error("No valid Steam library found via vdf or at default location.") diff --git a/jackify/backend/handlers/game_detector.py b/jackify/backend/handlers/game_detector.py index feeeb9e..1e47b77 100644 --- a/jackify/backend/handlers/game_detector.py +++ b/jackify/backend/handlers/game_detector.py @@ -18,7 +18,11 @@ class GameDetector: 'fallout3': ['Fallout 3'], 'oblivion': ['Oblivion'], 'starfield': ['Starfield'], - 'oblivion_remastered': ['Oblivion Remastered'] + 'oblivion_remastered': ['Oblivion Remastered'], + 'skyrimvr': ['Skyrim VR'], + 'fallout4vr': ['Fallout 4 VR'], + 'cp2077': ['Cyberpunk 2077'], + 'bg3': ["Baldur's Gate 3"], } def detect_game_type(self, modlist_name: str) -> Optional[str]: @@ -26,9 +30,17 @@ class GameDetector: modlist_lower = modlist_name.lower() # Check for game-specific keywords in modlist name - # Check for Oblivion Remastered first since "oblivion" is a substring + # Check more specific types before their generic parents if any(keyword in modlist_lower for keyword in ['oblivion remastered', 'oblivionremastered', 'oblivion_remastered']): return 'oblivion_remastered' + elif any(keyword in modlist_lower for keyword in ['skyrim vr', 'skyrimvr']): + return 'skyrimvr' + elif any(keyword in modlist_lower for keyword in ['fallout 4 vr', 'fallout4vr', 'fo4vr']): + return 'fallout4vr' + elif any(keyword in modlist_lower for keyword in ['cyberpunk', 'cp2077', 'cyberpunk 2077']): + return 'cp2077' + elif any(keyword in modlist_lower for keyword in ["baldur's gate 3", 'baldursgate3', 'bg3']): + return 'bg3' elif any(keyword in modlist_lower for keyword in ['skyrim', 'sse', 'skse', 'dragonborn', 'dawnguard']): return 'skyrim' elif any(keyword in modlist_lower for keyword in ['fallout 4', 'fo4', 'f4se', 'commonwealth']): @@ -134,9 +146,37 @@ class GameDetector: 'min_proton_version': '8.0', 'required_dlc': [], 'compatibility_tools': ['protontricks', 'winetricks'] - } + }, + 'skyrimvr': { + 'launcher': 'SKSE', + 'min_proton_version': '6.0', + 'required_dlc': [], + 'compatibility_tools': ['protontricks', 'winetricks'], + 'notes': 'SteamVR must be installed separately', + }, + 'fallout4vr': { + 'launcher': 'F4SE', + 'min_proton_version': '6.0', + 'required_dlc': [], + 'compatibility_tools': ['protontricks', 'winetricks'], + 'notes': 'SteamVR must be installed separately', + }, + 'cp2077': { + 'launcher': 'redmod', + 'min_proton_version': '8.0', + 'required_dlc': [], + 'compatibility_tools': ['protontricks', 'winetricks'], + 'notes': 'Requires WINEDLLOVERRIDES=version=n,b;winmm=n,b for Red4ext/CET. Rootbuilder must use COPY mode.', + }, + 'bg3': { + 'launcher': 'bg3_dx11', + 'min_proton_version': '8.0', + 'required_dlc': [], + 'compatibility_tools': ['protontricks', 'winetricks'], + 'notes': 'Rootbuilder must use COPY mode.', + }, } - + return requirements.get(game_type, {}) def detect_mods(self, modlist_path: Path) -> List[Dict]: diff --git a/jackify/backend/handlers/modlist_configuration.py b/jackify/backend/handlers/modlist_configuration.py index bfe7923..a1e98bf 100644 --- a/jackify/backend/handlers/modlist_configuration.py +++ b/jackify/backend/handlers/modlist_configuration.py @@ -2,7 +2,6 @@ from pathlib import Path import os import logging -import requests import re from typing import Optional @@ -147,52 +146,14 @@ class ModlistConfigurationMixin: 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: Download and apply curated user.reg.modlist and system.reg.modlist + # Step 3: Apply targeted registry tweaks (replaces wholesale curated reg file overwrite) if status_callback: - status_callback(f"{self._get_progress_timestamp()} Applying curated registry files for modlist configuration") - self.logger.info("Step 3: Downloading and applying curated user.reg.modlist and system.reg.modlist...") + status_callback(f"{self._get_progress_timestamp()} Applying modlist registry configuration") + self.logger.info("Step 3: Applying modlist registry tweaks...") try: - prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) - if not prefix_path_str or not os.path.isdir(prefix_path_str): - raise Exception("Could not determine Wine prefix path for this modlist. Please ensure you have launched the shortcut from Steam at least once.") - user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.modlist" - user_reg_dest = Path(prefix_path_str) / "user.reg" - response = requests.get(user_reg_url, verify=True) - response.raise_for_status() - with open(user_reg_dest, "wb") as f: - f.write(response.content) - self.logger.info(f"Curated user.reg.modlist downloaded and applied to {user_reg_dest}") - system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.modlist" - system_reg_dest = Path(prefix_path_str) / "system.reg" - response = requests.get(system_reg_url, verify=True) - response.raise_for_status() - with open(system_reg_dest, "wb") as f: - f.write(response.content) - self.logger.info(f"Curated system.reg.modlist downloaded and applied to {system_reg_dest}") + self._apply_modlist_registry_tweaks() except Exception as e: - self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}") - self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}") - return False - self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.") - # The curated registry files overwrite the entire Wine registry, so any - # game-specific entries injected earlier must be re-applied immediately after. - special_game_type = self.detect_special_game_type(self.modlist_dir) - if special_game_type in ["fnv", "fo3", "enderal"]: - self.logger.info( - "Re-injecting %s game registry entries after curated registry overwrite", - special_game_type.upper(), - ) - try: - from jackify.backend.services.automated_prefix_service import AutomatedPrefixService - AutomatedPrefixService()._inject_game_registry_entries(prefix_path_str, special_game_type) - except Exception as e: - self.logger.error( - "Failed to restore %s registry entries after curated registry overwrite: %s", - special_game_type.upper(), - e, - ) - self.logger.error("Could not restore required game registry entries after applying curated registry files.") - return False + self.logger.warning("Modlist registry tweaks failed (non-fatal): %s", e) # Step 4: Install Wine Components if status_callback: @@ -258,18 +219,12 @@ class ModlistConfigurationMixin: status_callback(f"{self._get_progress_timestamp()} {failure_msg}") # Continue but user should be aware of potential issues - # Step 4.6: Enable dotfiles visibility for Wine prefix - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Enabling dotfiles visibility") - self.logger.info("Step 4.6: Enabling dotfiles visibility in Wine prefix...") + # Step 4.6: Audit final registry state - confirms all writes survived winetricks + self.logger.info("Step 4.6: Auditing registry state...") try: - if self.protontricks_handler.enable_dotfiles(self.appid): - self.logger.info("Dotfiles visibility enabled successfully") - else: - self.logger.warning("Failed to enable dotfiles visibility (non-critical, continuing)") + self._audit_registry_state() except Exception as e: - self.logger.warning(f"Error enabling dotfiles visibility: {e} (non-critical, continuing)") - self.logger.info("Step 4.6: Enabling dotfiles visibility... Done") + self.logger.warning("Registry audit failed (non-fatal): %s", e) # Step 4.7: Create Wine prefix Documents directories for USVFS # Critical for USVFS profile INI virtualization on first launch @@ -277,17 +232,40 @@ class ModlistConfigurationMixin: status_callback(f"{self._get_progress_timestamp()} Creating Wine prefix Documents directories for USVFS") self.logger.info("Step 4.7: Creating Wine prefix Documents directories for USVFS...") try: - if self.appid and self.game_var: - # Map game_var to game_name for create_required_dirs + if self.appid: + # Map detected game type to the key expected by create_required_dirs game_name_map = { + "skyrim": "skyrimse", "skyrimspecialedition": "skyrimse", + "skyrimvr": "skyrimvr", + "fallout": "fallout4", "fallout4": "fallout4", + "fo4": "fallout4", + "fallout4vr": "fallout4vr", + "fnv": "falloutnv", "falloutnv": "falloutnv", "oblivion": "oblivion", - "enderalspecialedition": "enderalse" + "enderal": "enderalse", + "enderalspecialedition": "enderalse", + "bg3": "bg3", + "baldursgate3": "bg3", + "cp2077": "cp2077", + "starfield": "starfield", } - game_name = game_name_map.get(self.game_var.lower(), None) - + game_name = game_name_map.get((self.game_var or '').lower(), None) + + # Fallback: read gameName= directly from ModOrganizer.ini when loader-based + # detection returned Unknown (e.g. Enderal uses a non-SKSE launcher variant) + if not game_name and self.modlist_dir: + try: + from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist + _detected = detect_game_type_from_modlist(str(self.modlist_dir)) + if _detected: + game_name = game_name_map.get(_detected, _detected) + self.logger.info(f"Step 4.7: game type resolved via gameName= fallback: {_detected} -> {game_name}") + except Exception as _fe: + self.logger.debug(f"Step 4.7 fallback detection failed: {_fe}") + if game_name: appid_str = str(self.appid) if self.filesystem_handler.create_required_dirs(game_name, appid_str): @@ -295,13 +273,42 @@ class ModlistConfigurationMixin: else: self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)") else: - self.logger.debug(f"Game {self.game_var} not in directory creation map, skipping") + self.logger.debug(f"Game {self.game_var!r} not in directory creation map, skipping") else: - self.logger.warning("AppID or game_var not available, skipping Wine prefix Documents directory creation") + self.logger.warning("AppID not available, skipping Wine prefix Documents directory creation") except Exception as e: self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)") self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done") + # Step 4.8: Configure nxmhandler.ini to suppress MO2 NXM registration popup + self.logger.info("Step 4.8: Configuring nxmhandler.ini...") + try: + self._configure_nxmhandler_ini() + except Exception as e: + self.logger.debug(f"nxmhandler.ini configuration failed (non-critical): {e}") + self.logger.info("Step 4.8: Configuring nxmhandler.ini... Done") + + # Step 4.9: Inject game install path registry entries (FNV/FO3/Enderal/CP2077/BG3). + # Required so the game launcher and engine can locate the base game when + # MO2 is running inside the Proton prefix. Idempotent: safe to run on + # reinstall or re-configure. + self.logger.info("Step 4.9: Injecting game registry entries...") + try: + compatdata_path = self.path_handler.find_compat_data(str(self.appid)) + if compatdata_path: + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + from jackify.backend.models.configuration import SystemInfo + from jackify.backend.services.platform_detection_service import PlatformDetectionService + _svc = AutomatedPrefixService(SystemInfo( + is_steamdeck=PlatformDetectionService.get_instance().is_steamdeck + )) + _svc._inject_game_registry_entries(str(compatdata_path), self.game_var or '') + else: + self.logger.debug("Compatdata path not found for game registry injection, skipping") + except Exception as e: + self.logger.warning("Game registry injection failed (non-fatal): %s", e) + self.logger.info("Step 4.9: Injecting game registry entries... Done") + # Step 5: Verify ownership of Modlist directory if status_callback: status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership") @@ -328,6 +335,10 @@ class ModlistConfigurationMixin: self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}") self.logger.info("Step 6: Backing up ModOrganizer.ini... Done") + # Step 6.1: BG3-specific patches to ModOrganizer.ini and MO2 plugins + self._patch_bg3_mod_settings_plugin() + self._set_bg3_rootbuilder_copy_mode() + # Step 6.5: Handle symlinked downloads directory if status_callback: status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory") @@ -419,6 +430,21 @@ class ModlistConfigurationMixin: self.logger.info("Set download_directory in ModOrganizer.ini (Install flow)") else: self.logger.warning("Could not set download_directory in ModOrganizer.ini") + elif modlist_ini_path_obj.is_file(): + # Configure Existing / Configure New flows: no explicit download_dir is set, but the + # INI may have duplicate or mangled entries from the original Wabbajack install. + # Read the first valid value, then re-write all occurrences to that value so MO2 + # reads the correct path regardless of which occurrence it picks up last. + existing_linux = self.path_handler.get_download_directory_linux_path(modlist_ini_path_obj) + if existing_linux: + if self.path_handler.set_download_directory( + modlist_ini_path_obj, existing_linux, self.modlist_sdcard + ): + self.logger.info("Normalised download_directory entries in ModOrganizer.ini") + else: + self.logger.warning("Could not normalise download_directory in ModOrganizer.ini") + else: + 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 @@ -584,6 +610,47 @@ class ModlistConfigurationMixin: status_callback(f"{self._get_progress_timestamp()} Re-applying final Windows compatibility settings") self._re_enforce_windows_10_mode() + # Step 15: Apply tool compatibility settings (xEdit, Pandora, DLL overrides). + # Only runs for standard Skyrim SE/AE modlists. Non-Skyrim games (Enderal, FNV, + # FO3, etc.) are excluded because the mscoree AppDefault targets SkyrimSE.exe, + # which is also Enderal's executable, causing a crash on those modlists. + _special_type = self.detect_special_game_type(self.modlist_dir) + try: + from jackify.backend.handlers.config_handler import ConfigHandler + if ConfigHandler().get('auto_tool_compat', True) and _special_type is None: + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Applying tool compatibility settings") + self.logger.info("Step 15: Applying tool compatibility settings...") + compatdata_path = str(wineprefix).replace("/pfx", "").rstrip("/") + 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 + 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_fxc2_d3dcompiler=True, + ) + self.logger.info("Step 15: Tool compatibility settings applied") + else: + self.logger.warning("Step 15: Could not resolve prefix path or wine binary - skipping tool compat") + elif _special_type is not None: + self.logger.info(f"Step 15: Skipping tool compat for {_special_type} modlist") + except Exception as e: + self.logger.warning("Step 15: Tool compatibility settings failed (non-fatal): %s", e) + + # Step 16: Nemesis compatibility setup (symlink + workingDirectory fix) + try: + from jackify.backend.services.tool_config_service import setup_nemesis_compatibility + setup_nemesis_compatibility( + modlist_dir=self.modlist_dir, + stock_game_path=self.stock_game_path, + log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None, + ) + except Exception as e: + self.logger.warning("Step 16: Nemesis setup failed (non-fatal): %s", e) + return True # Return True on success def run_modlist_configuration_phase(self, context: dict = None) -> bool: @@ -597,6 +664,48 @@ class ModlistConfigurationMixin: status_callback = context.get('status_callback') if context else None return self._execute_configuration_steps(status_callback=status_callback) + def _configure_nxmhandler_ini(self) -> None: + """ + Set noregister=true in nxmhandler.ini in the MO2 install directory. + + MO2 reads this flag on startup and skips the NXM handler registration + popup when it is true. On Linux, MO2's NXM handler cannot be registered + usefully via Wine; Jackify will become its own NXM handler in a later cycle. + Safe to apply on every configuration run - always correct on Linux. + """ + if not self.modlist_dir: + return + + nxm_ini_path = os.path.join(self.modlist_dir, "nxmhandler.ini") + + try: + if os.path.exists(nxm_ini_path): + with open(nxm_ini_path, 'r', encoding='utf-8') as f: + content = f.read() + + if re.search(r'(?im)^\s*noregister\s*=\s*true\s*$', content): + self.logger.debug("nxmhandler.ini noregister already true, skipping") + return + + # Replace existing noregister=... line if present, otherwise inject after [General] + if re.search(r'(?im)^\s*noregister\s*=', content): + content = re.sub(r'(?im)^\s*noregister\s*=.*$', 'noregister=true', content) + elif re.search(r'(?im)^\s*\[General\]', content): + content = re.sub(r'(?im)(^\s*\[General\]\s*\n)', r'\1noregister=true\n', content) + else: + content += '\n[General]\nnoregister=true\n' + + with open(nxm_ini_path, 'w', encoding='utf-8') as f: + f.write(content) + self.logger.info(f"Set noregister=true in {nxm_ini_path}") + else: + # MO2 creates nxmhandler.ini on first run; pre-create with the flag set + with open(nxm_ini_path, 'w', encoding='utf-8') as f: + f.write("[General]\nnoregister=true\n") + self.logger.info(f"Created nxmhandler.ini with noregister=true: {nxm_ini_path}") + except Exception as e: + self.logger.warning(f"Could not configure nxmhandler.ini: {e}") + def _prompt_or_set_resolution(self): # If on Steam Deck, set 1280x800 automatically if self._is_steam_deck(): @@ -617,3 +726,73 @@ class ModlistConfigurationMixin: else: self.selected_resolution = None self.logger.info("Resolution setup skipped by user.") + + def _patch_bg3_mod_settings_plugin(self) -> None: + """ + Fix a bug in the BG3 MO2 plugin (Alvadus/BG3-MO2-Unofficial-Plugin) where + mods_order_node is conditionally created but unconditionally referenced. + Bug present in upstream source as of 2026-03; author not yet notified. + Safe to apply: always creating the ModOrder node is valid BG3 XML regardless of mod count. + """ + import os + if not self.modlist_dir: + return + plugin_path = os.path.join( + str(self.modlist_dir), + "plugins", "basic_games", "games", "baldursgate3", "modSettings.py" + ) + if not os.path.exists(plugin_path): + self.logger.debug("BG3 modSettings.py plugin not found, skipping patch") + return + try: + with open(plugin_path, 'r', encoding='utf-8') as f: + content = f.read() + buggy = ( + " if len(mod_settings) > 1:\n" + " mods_order_node = ET.SubElement(children, \"node\")\n" + " mods_order_node.set(\"id\", \"ModOrder\")" + ) + fixed = ( + " mods_order_node = ET.SubElement(children, \"node\")\n" + " mods_order_node.set(\"id\", \"ModOrder\")" + ) + if buggy in content: + content = content.replace(buggy, fixed) + with open(plugin_path, 'w', encoding='utf-8') as f: + f.write(content) + self.logger.info("Applied modSettings.py patch for BG3 MO2 plugin") + elif fixed in content: + self.logger.debug("BG3 modSettings.py already patched, skipping") + else: + self.logger.warning("BG3 modSettings.py patch target not found - plugin may have changed upstream") + except Exception as e: + self.logger.warning(f"Could not patch BG3 modSettings.py: {e} (non-critical, continuing)") + + def _set_bg3_rootbuilder_copy_mode(self) -> None: + """ + Switch Root Builder to copy mode in ModOrganizer.ini for BG3 modlists. + Link mode (the shipped default) fails on Linux - files are not accessible + to the game process across the Wine boundary. Copy mode works reliably. + Applied unconditionally: copy mode is safe regardless of drive layout. + Detected by presence of RootBuilder keys rather than game_var (unreliable for BG3). + """ + import os, re + if not self.modlist_dir: + return + mo2_ini = os.path.join(str(self.modlist_dir), "ModOrganizer.ini") + if not os.path.exists(mo2_ini): + self.logger.debug("ModOrganizer.ini not found, skipping Root Builder copy mode patch") + return + try: + with open(mo2_ini, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + if 'RootBuilder\\' not in content and 'RootBuilder/' not in content: + self.logger.debug("Root Builder not present in ModOrganizer.ini, skipping") + return + content = re.sub(r'^(RootBuilder\\copyfiles\s*=).*$', r'\1**', content, flags=re.MULTILINE) + content = re.sub(r'^(RootBuilder\\linkfiles\s*=).*$', r'\1', content, flags=re.MULTILINE) + with open(mo2_ini, 'w', encoding='utf-8') as f: + f.write(content) + self.logger.info("Set Root Builder to copy mode in ModOrganizer.ini") + except Exception as e: + self.logger.warning(f"Could not set Root Builder copy mode: {e} (non-critical, continuing)") diff --git a/jackify/backend/handlers/modlist_detection.py b/jackify/backend/handlers/modlist_detection.py index 78b3504..8fcdbca 100644 --- a/jackify/backend/handlers/modlist_detection.py +++ b/jackify/backend/handlers/modlist_detection.py @@ -253,12 +253,13 @@ class ModlistDetectionMixin: modlist_path = Path(self.modlist_dir) common_names = [ "Stock Game", - "Game Root", + "StockGame", "STOCK GAME", "Stock Game Folder", "Stock Folder", "Skyrim Stock", - Path("root/Skyrim Special Edition") + Path("root/Skyrim Special Edition"), + "Game Root", ] found_path = None @@ -326,6 +327,15 @@ class ModlistDetectionMixin: if mo2_ini.exists(): try: content = mo2_ini.read_text(errors='ignore').lower() + # Extract gameName= for authoritative game type checks. + # Full-content scans can false-positive on plugin setting keys + # (e.g. enable_skyrimVR=false in a Skyrim SE ini). + game_name_value = "" + for _line in content.splitlines(): + stripped_line = _line.strip() + if stripped_line.startswith("gamename="): + game_name_value = stripped_line[len("gamename="):] + break if 'nvse' in content or 'nvse_loader' in content or 'fallout new vegas' in content or 'falloutnv' in content: self.logger.info("Detected FNV via ModOrganizer.ini markers") return "fnv" @@ -335,6 +345,18 @@ class ModlistDetectionMixin: if any(pattern in content for pattern in ['enderal launcher', 'enderal.exe', 'enderal launcher.exe', 'enderalsteam']): self.logger.info("Detected Enderal via ModOrganizer.ini markers") return "enderal" + if 'cyberpunk 2077' in content or 'cyberpunk2077' in content or 'cp2077' in content: + self.logger.info("Detected Cyberpunk 2077 via ModOrganizer.ini markers") + return "cp2077" + if "baldur's gate 3" in content or 'baldursgate3' in content or 'bg3' in content: + self.logger.info("Detected Baldur's Gate 3 via ModOrganizer.ini markers") + return "bg3" + if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value: + self.logger.info("Detected SkyrimVR via ModOrganizer.ini gameName") + return "skyrimvr" + if 'fallout 4 vr' in game_name_value or 'fallout4vr' in game_name_value: + self.logger.info("Detected Fallout 4 VR via ModOrganizer.ini gameName") + return "fallout4vr" except Exception as e: self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}") except Exception: @@ -364,6 +386,15 @@ class ModlistDetectionMixin: if enderal_launcher.exists(): self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'") return "enderal" + cp2077_exe = base / "Cyberpunk2077.exe" + if cp2077_exe.exists(): + self.logger.info(f"Detected Cyberpunk 2077 modlist: found Cyberpunk2077.exe in '{base}'") + return "cp2077" + bg3_exe = base / "bg3.exe" + bg3_dx11_exe = base / "bg3_dx11.exe" + if bg3_exe.exists() or bg3_dx11_exe.exists(): + self.logger.info(f"Detected BG3 modlist: found BG3 executable in '{base}'") + return "bg3" # Final heuristic using game_var try: @@ -379,6 +410,18 @@ class ModlistDetectionMixin: if 'enderal' in gt: self.logger.info("Heuristic detection: game_var indicates Enderal") return "enderal" + if 'cyberpunk' in gt or 'cp2077' in gt: + self.logger.info("Heuristic detection: game_var indicates Cyberpunk 2077") + return "cp2077" + if "baldur" in gt or 'bg3' in gt: + self.logger.info("Heuristic detection: game_var indicates BG3") + return "bg3" + if 'skyrim vr' in gt or 'skyrimvr' in gt: + self.logger.info("Heuristic detection: game_var indicates SkyrimVR") + return "skyrimvr" + if 'fallout 4 vr' in gt or 'fallout4vr' in gt: + self.logger.info("Heuristic detection: game_var indicates Fallout 4 VR") + return "fallout4vr" except Exception: pass diff --git a/jackify/backend/handlers/modlist_handler.py b/jackify/backend/handlers/modlist_handler.py index 5e7fb50..493d8fd 100644 --- a/jackify/backend/handlers/modlist_handler.py +++ b/jackify/backend/handlers/modlist_handler.py @@ -61,32 +61,9 @@ class ModlistHandler(ModlistDetectionMixin, ModlistConfigurationMixin, ModlistWi Handles operations related to modlist detection and configuration """ - # Dictionary mapping modlist name patterns (lowercase, spaces optional) - # to lists of additional Wine components or special actions. - MODLIST_SPECIFIC_COMPONENTS = { - # Pattern: [component1, component2, ... or special_action_string] - "wildlander": ["dotnet48"], # Example from bash script - "licentia": ["dotnet8"], # Example from bash script (needs special handling) - "nolvus": ["dotnet6", "dotnet7"], # Example - # Add other modlists and their specific needs here - # e.g., "fallout4_anotherlife": ["some_component"] - } - - # Canonical mapping of modlist-specific Wine components (from omni-guides.sh) - # dotnet4.x components disabled in v0.1.6.2 -- replaced with universal registry fixes - MODLIST_WINE_COMPONENTS = { - # "wildlander": ["dotnet472"], # DISABLED: Universal registry fixes replace dotnet472 installation - # "librum": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40 - "librum": ["dotnet8"], # dotnet40 replaced with universal registry fixes - # "apostasy": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40 - "apostasy": ["dotnet8"], # dotnet40 replaced with universal registry fixes - # "nordicsouls": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation - # "livingskyrim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation - # "lsiv": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation - # "ls4": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation - # "lorerim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation - # "lostlegacy": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation - } + MODLIST_SPECIFIC_COMPONENTS: dict = {} + + MODLIST_WINE_COMPONENTS: dict = {} def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None, mo2_path: Optional[Union[str, Path]] = None, diff --git a/jackify/backend/handlers/modlist_install_cli_configuration.py b/jackify/backend/handlers/modlist_install_cli_configuration.py index 2639c38..cc58721 100644 --- a/jackify/backend/handlers/modlist_install_cli_configuration.py +++ b/jackify/backend/handlers/modlist_install_cli_configuration.py @@ -159,14 +159,17 @@ class ModlistInstallCLIConfigurationMixin: self.logger.info(f"Using machineid: {machineid}") cmd += ['-o', install_dir_str, '-d', download_dir_str] + writeback_path = str(auth_service.get_token_writeback_path()) # Store original environment values to restore later original_env_values = { 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), '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 # Temporarily modify current process's environment # Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy) if oauth_info: @@ -341,7 +344,8 @@ class ModlistInstallCLIConfigurationMixin: print() proc.wait() - + auth_service.apply_token_writeback(writeback_path) + finally: # Stop performance monitoring and get summary if monitoring_started: diff --git a/jackify/backend/handlers/modlist_wine_ops.py b/jackify/backend/handlers/modlist_wine_ops.py index 3ee60af..1567460 100644 --- a/jackify/backend/handlers/modlist_wine_ops.py +++ b/jackify/backend/handlers/modlist_wine_ops.py @@ -59,33 +59,11 @@ class ModlistWineOpsMixin: self.logger.error("Could not locate Steam's config.vdf file.") return False, 'config_vdf_missing' - # Add a short delay to allow Steam to potentially finish writing changes - self.logger.debug("Waiting 2 seconds before reading config.vdf...") - time.sleep(2) - try: - self.logger.debug(f"Attempting to load VDF file: {config_vdf_path}") - # CORRECTION: Use the vdf library directly here, not VDFHandler + self.logger.debug(f"Loading config.vdf: {config_vdf_path}") with open(str(config_vdf_path), 'r') as f: - config_data = vdf.load(f, mapper=vdf.VDFDict) + config_data = vdf.load(f, mapper=vdf.VDFDict) - # --- Write full config.vdf to a debug file --- - debug_dump_path = os.path.expanduser("~/dev/Jackify/configvdf_dump.txt") - with open(debug_dump_path, "w") as dump_f: - json.dump(config_data, dump_f, indent=2) - self.logger.info(f"Full config.vdf dumped to {debug_dump_path}") - - # --- Log only the relevant section for this AppID --- - 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, {}) - self.logger.debug("───────────────────────────────────────────────────────────────────") - self.logger.debug(f"Config.vdf entry for AppID {appid_to_check} (CompatToolMapping):") - self.logger.debug(json.dumps({appid_to_check: app_mapping}, indent=2)) - self.logger.debug("───────────────────────────────────────────────────────────────────") - self.logger.debug(f"Steam config section from VDF: {json.dumps(steam_config_section, indent=2)}") - # --- End Debugging --- - # Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name compat_mapping = steam_config_section.get('CompatToolMapping', {}) app_mapping = compat_mapping.get(appid_to_check, {}) @@ -152,14 +130,24 @@ class ModlistWineOpsMixin: self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.") return True, 'ok' - def set_steam_grid_images(self, appid: str, modlist_dir: str): + def set_steam_grid_images(self, appid: str, modlist_dir: str, game_type: str = None): """ - Copies hero, logo, and poster images from the modlist's SteamIcons directory - to the grid directory of all non-zero Steam user directories, named after the new AppID. + Copies artwork from the modlist's SteamIcons directory to Steam's grid folder. + Falls back to SteamGridDB if no SteamIcons directory is present and an API key + is configured. """ + if modlist_dir: + try: + from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist + detected_game_type = detect_game_type_from_modlist(modlist_dir) + if detected_game_type: + game_type = detected_game_type + except Exception as e: + self.logger.debug(f"Steam artwork game type auto-detect failed for {modlist_dir}: {e}") + steam_icons_dir = Path(modlist_dir) / "SteamIcons" if not steam_icons_dir.is_dir(): - self.logger.info(f"No SteamIcons directory found at {steam_icons_dir}, skipping grid image copy.") + self._try_steamgriddb_artwork(appid, game_type, modlist_dir) return # Find all non-zero Steam user directories @@ -177,8 +165,8 @@ class ModlistWineOpsMixin: images = [ ("grid-hero.png", f"{appid}_hero.png"), ("grid-logo.png", f"{appid}_logo.png"), - ("grid-tall.png", f"{appid}.png"), ("grid-tall.png", f"{appid}p.png"), + ("grid-wide.png", f"{appid}.png"), ] for src_name, dest_name in images: @@ -191,7 +179,85 @@ class ModlistWineOpsMixin: except Exception as e: self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}") else: - self.logger.warning(f"Image {src_path} not found; skipping.") + self.logger.debug(f"Image {src_path} not found; skipping.") + + # Tenfoot: use explicit file if provided, otherwise resize the landscape grid + tenfoot_src = steam_icons_dir / "grid-tenfoot.png" + tenfoot_dest = grid_dir / f"{appid}_tenfoot.png" + wide_src = steam_icons_dir / "grid-wide.png" + if tenfoot_src.exists(): + try: + shutil.copyfile(tenfoot_src, tenfoot_dest) + self.logger.info(f"Copied {tenfoot_src} to {tenfoot_dest}") + except Exception as e: + 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}") + except Exception as e: + self.logger.warning(f"Could not generate 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.""" + 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) + if not game_type: + self.logger.warning(f"SteamGridDB fallback skipped: could not detect game type for {modlist_dir}") + return + + userdata_base = Path.home() / ".steam/steam/userdata" + if not userdata_base.is_dir(): + return + + import tempfile + with tempfile.TemporaryDirectory() as tmp: + tmp_dir = Path(tmp) + from jackify.backend.services.steamgriddb_service import fetch_artwork + count = fetch_artwork(game_type, tmp_dir) + if count == 0: + self.logger.debug(f"SteamGridDB returned no artwork for game type: {game_type}") + return + + 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-tall.png", f"{appid}p.png"), + ("grid-wide.png", f"{appid}.png"), + ("grid-hero.png", f"{appid}_hero.png"), + ("grid-logo.png", f"{appid}_logo.png"), + ] + for src_name, dest_name in images: + src = tmp_dir / src_name + if src.exists(): + try: + shutil.copyfile(src, grid_dir / dest_name) + except Exception as e: + self.logger.warning(f"Failed to copy {src_name}: {e}") + + # Generate tenfoot from landscape + wide = tmp_dir / "grid-wide.png" + if wide.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")) + except Exception as e: + self.logger.debug(f"Could not generate tenfoot: {e}") + + self.logger.info(f"Applied SteamGridDB artwork for game type '{game_type}' ({count} images)") def get_modlist_wine_components(self, modlist_name, game_var_full=None): """ @@ -206,12 +272,16 @@ class ModlistWineOpsMixin: 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: - extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"] + 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"] + elif "cp2077" in game or "cyberpunk" in game: + extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"] + elif "bg3" in game or "baldursgate" in game: + extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"] else: - # Unknown game type — install the union of all known component sets - extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "d3dx9_43", "d3dx9"] + # Unknown game type - install the union of all known component sets + extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "d3dx9_43", "d3dx9"] # Add modlist-specific extras modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else "" for key, components in self.MODLIST_WINE_COMPONENTS.items(): @@ -224,37 +294,49 @@ class ModlistWineOpsMixin: def _re_enforce_windows_10_mode(self): """ - Re-enforce Windows 10 mode after modlist-specific configurations. - This matches the legacy script behavior (line 1333) where Windows 10 mode - is re-applied after modlist-specific steps to ensure consistency. + Re-enforce the final Windows version after modlist-specific configurations. + Re-applies win10 after modlist-specific winetricks components, which can + leave the prefix at a lower version. """ try: if not hasattr(self, 'appid') or not self.appid: - self.logger.warning("Cannot re-enforce Windows 10 mode - no AppID available") + self.logger.warning("Cannot re-enforce Windows 11 mode - no AppID available") return from ..handlers.winetricks_handler import WinetricksHandler from ..handlers.path_handler import PathHandler - # Get prefix path for the AppID - prefix_path = PathHandler.find_compat_data(str(self.appid)) - if not prefix_path: - self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found") + # Get prefix path for the AppID - must be compatdata/pfx/, not compatdata/ + compatdata_path = PathHandler.find_compat_data(str(self.appid)) + if not compatdata_path: + self.logger.warning("Cannot re-enforce Windows 11 mode - prefix path not found") return + prefix_path = compatdata_path / "pfx" - # Use winetricks handler to set Windows 10 mode + # Use winetricks handler to set Windows 11 mode winetricks_handler = WinetricksHandler() wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path)) if not wine_binary: - self.logger.warning("Cannot re-enforce Windows 10 mode - wine binary not found") + self.logger.warning("Cannot re-enforce Windows 11 mode - wine binary not found") return - winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary) - - self.logger.info("Windows 10 mode re-enforced after modlist-specific configurations") + env = os.environ.copy() + env['WINEPREFIX'] = str(prefix_path) + env['WINE'] = wine_binary + result = subprocess.run( + [winetricks_handler.winetricks_path, '-q', 'win10'], + env=env, + capture_output=True, + text=True, + timeout=300 + ) + if result.returncode == 0: + self.logger.info("Windows 11 mode re-enforced after modlist-specific configurations") + else: + self.logger.warning("Could not set Windows 11 mode: %s", result.stderr) except Exception as e: - self.logger.warning(f"Error re-enforcing Windows 10 mode: {e}") + self.logger.warning(f"Error re-enforcing Windows 11 mode: {e}") def _handle_symlinked_downloads(self) -> bool: """ @@ -380,21 +462,17 @@ class ModlistWineOpsMixin: env['WINEPREFIX'] = prefix_path env['WINEDEBUG'] = '-all' # Suppress Wine debug output - # Shutdown any running wineserver processes to ensure clean slate - if wineserver_binary: - self.logger.debug("Shutting down wineserver before applying registry fixes...") - try: - subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True) - self.logger.debug("Wineserver shutdown complete") - except Exception as e: - self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}") + self._wait_for_wineserver(prefix_path) - # Registry fix 1: Set *mscoree=native DLL override (asterisk for full override) - # Use native .NET runtime instead of Wine's - self.logger.debug("Setting *mscoree=native DLL override...") + # Registry fix 1: Set *mscoree=native as a per-exe AppDefaults override for + # SkyrimSE.exe only. A global DllOverrides entry breaks .NET 9/10 bootstrap + # (Synthesis), because the override intercepts mscoree loading for ALL processes + # including the SDK host. Scoping it to SkyrimSE.exe isolates the fix to the + # game process without affecting Synthesis or any other .NET tool. + self.logger.debug("Setting *mscoree=native AppDefaults override for SkyrimSE.exe...") cmd1 = [ wine_binary, 'reg', 'add', - 'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides', + 'HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides', '/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f' ] @@ -430,43 +508,12 @@ class ModlistWineOpsMixin: except Exception as e: self.logger.warning(f"Registry flush failed (non-critical): {e}") - # VERIFICATION: Confirm the registry entries persisted - self.logger.info("Verifying registry entries were applied and persisted...") - verification_passed = True - - # Verify *mscoree=native - verify_cmd1 = [ - wine_binary, 'reg', 'query', - 'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides', - '/v', '*mscoree' - ] - verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30) - if verify_result1.returncode == 0 and 'native' in verify_result1.stdout: - self.logger.info("VERIFIED: *mscoree=native is set correctly") + ok = result1.returncode == 0 and result2.returncode == 0 + if ok: + self.logger.info("Universal dotnet4.x compatibility fixes applied and flushed") else: - self.logger.error(f"VERIFICATION FAILED: *mscoree=native not found in registry. Query output: {verify_result1.stdout}") - verification_passed = False - - # Verify OnlyUseLatestCLR=1 - verify_cmd2 = [ - wine_binary, 'reg', 'query', - 'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework', - '/v', 'OnlyUseLatestCLR' - ] - verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30) - if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout): - self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly") - else: - self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}") - verification_passed = False - - # Both fixes applied and verified - if result1.returncode == 0 and result2.returncode == 0 and verification_passed: - self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully") - return True - else: - self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts") - return False + self.logger.error("One or more dotnet4.x registry commands failed - see errors above") + return ok except Exception as e: self.logger.error(f"Failed to apply universal dotnet4.x fixes: {e}") @@ -506,6 +553,204 @@ class ModlistWineOpsMixin: self.logger.error(f"Error finding Wine binary: {e}") return None + def _wait_for_wineserver(self, prefix_path: str) -> None: + """Wait for wineserver to stop for the given prefix before direct file edits. + + Harmless if wineserver is already stopped - exits immediately. + Prevents in-memory hive flush from overwriting direct .reg file edits. + """ + wine_binary = self._find_wine_binary_for_registry() + if not wine_binary: + self.logger.debug("No wine binary found; skipping wineserver wait") + return + wineserver = os.path.join(os.path.dirname(wine_binary), "wineserver") + if not os.path.exists(wineserver): + self.logger.debug("wineserver binary not found; skipping wait") + return + env = os.environ.copy() + env["WINEPREFIX"] = prefix_path + env["WINEDEBUG"] = "-all" + try: + subprocess.run([wineserver, "-w"], env=env, timeout=30, capture_output=True) + self.logger.debug("wineserver stopped for prefix %s", prefix_path) + except Exception as e: + self.logger.debug("wineserver wait returned non-zero (likely already stopped): %s", e) + + def _apply_modlist_registry_tweaks(self) -> bool: + """Write user.reg values required for modlist operation. + + - FontSmoothing/Type/Gamma/Orientation (ClearType subpixel rendering) + - HIGHDPIAWARE (prevents Wine DPI scaling on tools) + - ShowDotFiles=Y (MO2 must see hidden dirs inside the prefix) + """ + try: + prefix_path = os.path.join(str(self.compat_data_path), "pfx") + user_reg = os.path.join(prefix_path, "user.reg") + if not os.path.exists(user_reg): + self.logger.warning("user.reg not found at %s; skipping modlist registry tweaks", user_reg) + return False + + self._wait_for_wineserver(prefix_path) + + tweaks = [ + ( + "[Control Panel\\\\Desktop]", + '"FontSmoothing"', + '"2"', + ), + ( + "[Control Panel\\\\Desktop]", + '"FontSmoothingGamma"', + "dword:00000578", + ), + ( + "[Control Panel\\\\Desktop]", + '"FontSmoothingOrientation"', + "dword:00000001", + ), + ( + "[Control Panel\\\\Desktop]", + '"FontSmoothingType"', + "dword:00000002", + ), + ( + "[Software\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\AppCompatFlags\\\\Layers]", + '@', + '"~ HIGHDPIAWARE"', + ), + ( + "[Software\\\\Wine]", + '"ShowDotFiles"', + '"Y"', + ), + ] + + with open(user_reg, "r", encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + + for section, key, value in tweaks: + in_section = False + updated = False + insert_at = None + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.lower() == section.lower(): + in_section = True + continue + if stripped.startswith("[") and in_section: + insert_at = i + break + if in_section and stripped.lower().startswith(key.lower()): + lines[i] = f"{key}={value}\n" + updated = True + break + + if not updated: + entry = f"{key}={value}\n" + if insert_at is not None: + lines.insert(insert_at, entry) + elif in_section: + lines.append(entry) + else: + lines.append(f"\n{section}\n") + lines.append(entry) + + with open(user_reg, "w", encoding="utf-8") as f: + f.writelines(lines) + + self.logger.info("Modlist registry tweaks applied (font smoothing, HIGHDPIAWARE, ShowDotFiles)") + return True + + except Exception as e: + self.logger.error("Failed to apply modlist registry tweaks: %s", e) + return False + + def _audit_registry_state(self) -> bool: + """Read user.reg and system.reg and log whether every expected value is present. + + Returns True only when all checks pass. Logs a WARNING for each missing or + wrong value so the application log always carries a clear post-configuration + record of registry state. + """ + try: + prefix_path = os.path.join(str(self.compat_data_path), "pfx") + user_reg = os.path.join(prefix_path, "user.reg") + system_reg = os.path.join(prefix_path, "system.reg") + + def _read(path): + if not os.path.exists(path): + return "" + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return f.read() + + user_content = _read(user_reg) + system_content = _read(system_reg) + + checks = [ + # (description, file_content, expected_substring) + ( + "ShowDotFiles=Y (user.reg)", + user_content, + '"ShowDotFiles"="Y"', + ), + ( + "FontSmoothing=2 (user.reg)", + user_content, + '"FontSmoothing"="2"', + ), + ( + "FontSmoothingType=2 (user.reg)", + user_content, + '"FontSmoothingType"=dword:00000002', + ), + ( + "FontSmoothingGamma (user.reg)", + user_content, + '"FontSmoothingGamma"=dword:00000578', + ), + ( + "FontSmoothingOrientation (user.reg)", + user_content, + '"FontSmoothingOrientation"=dword:00000001', + ), + ( + "HIGHDPIAWARE (user.reg)", + user_content, + 'HIGHDPIAWARE', + ), + ( + "*mscoree=native (user.reg)", + user_content, + '"*mscoree"="native"', + ), + ( + "OnlyUseLatestCLR=1 (system.reg)", + system_content, + '"OnlyUseLatestCLR"=dword:00000001', + ), + ] + + all_ok = True + for description, content, needle in checks: + if needle in content: + self.logger.info("Registry audit [OK] %s", description) + else: + self.logger.warning("Registry audit [MISSING] %s", description) + all_ok = False + + if all_ok: + self.logger.info("Registry audit complete - all values confirmed present") + else: + self.logger.warning( + "Registry audit complete - one or more values missing; " + "see [MISSING] entries above" + ) + return all_ok + + except Exception as e: + self.logger.error("Registry audit failed with exception: %s", e) + return False + def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]: """ Recursively search for wine binary within a Proton directory. @@ -543,4 +788,3 @@ class ModlistWineOpsMixin: except Exception as e: self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}") return None - diff --git a/jackify/backend/handlers/path_handler_game.py b/jackify/backend/handlers/path_handler_game.py index 01a0cd0..84ec6ac 100644 --- a/jackify/backend/handlers/path_handler_game.py +++ b/jackify/backend/handlers/path_handler_game.py @@ -180,5 +180,6 @@ class PathHandlerGameMixin: self.stock_game_path = found_path return True self.stock_game_path = None - self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.") + searched = [str(modlist_path / n) for n in preferred_order] + self.logger.info(f"No common Stock Game/Game Root directory found (searched: {searched}). Will assume vanilla game path is needed for some operations.") return True diff --git a/jackify/backend/handlers/path_handler_mo2.py b/jackify/backend/handlers/path_handler_mo2.py index 8b3509a..51588e0 100644 --- a/jackify/backend/handlers/path_handler_mo2.py +++ b/jackify/backend/handlers/path_handler_mo2.py @@ -534,7 +534,8 @@ class PathHandlerMO2Mixin: def set_download_directory(self, modlist_ini_path: Path, download_dir_linux_path, modlist_sdcard: bool) -> bool: """ Set download_directory in ModOrganizer.ini to the correct Wine path (Z: or D: for SD card). - Use only when download dir is known (e.g. Install a Modlist flow). Configure New/Existing leave as-is. + Replaces ALL occurrences of the key throughout the file - MO2 reads the last one, and + duplicate [General] sections from Wabbajack installs are common. """ if not modlist_ini_path.is_file() or not download_dir_linux_path: return False @@ -553,35 +554,62 @@ class PathHandlerMO2Mixin: formatted = PathHandlerMO2Mixin._format_workingdir_for_mo2(wine_path) with open(modlist_ini_path, 'r', encoding='utf-8') as f: lines = f.readlines() - in_general = False - download_line_idx = -1 - for i, line in enumerate(lines): - if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE): - in_general = True - continue - if in_general and re.match(r'^\s*\[', line): - break - if in_general and re.match(r'^\s*download_directory\s*=', line, re.IGNORECASE): - download_line_idx = i - break new_line = f"download_directory = {formatted}\n" - if download_line_idx >= 0: - lines[download_line_idx] = new_line + replaced = [i for i, l in enumerate(lines) if re.match(r'^\s*download_directory\s*=', l, re.IGNORECASE)] + if replaced: + for i in replaced: + lines[i] = new_line else: - if in_general: - insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1) - if insert_idx >= 0: + # No existing entry - insert after [General] + insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1) + if insert_idx >= 0: + insert_idx += 1 + while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]): insert_idx += 1 - while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]): - insert_idx += 1 - lines.insert(insert_idx, new_line) + lines.insert(insert_idx, new_line) else: lines.append("[General]\n") lines.append(new_line) with open(modlist_ini_path, 'w', encoding='utf-8') as f: f.writelines(lines) - logger.info(f"Set download_directory in ModOrganizer.ini to {formatted}") + logger.info(f"Set download_directory in ModOrganizer.ini to {formatted} ({len(replaced)} occurrence(s))") return True except Exception as e: logger.error(f"Error setting download_directory in {modlist_ini_path}: {e}") return False + + def get_download_directory_linux_path(self, modlist_ini_path: Path) -> Optional[str]: + """ + Read the first valid download_directory value from ModOrganizer.ini and convert to a Linux path. + Returns None if no valid Z: or D: path is found. + """ + if not modlist_ini_path.is_file(): + return None + try: + with open(modlist_ini_path, 'r', encoding='utf-8-sig') as f: + lines = f.readlines() + except UnicodeDecodeError: + try: + with open(modlist_ini_path, 'r', encoding='latin-1') as f: + lines = f.readlines() + except Exception: + return None + except Exception: + return None + for line in lines: + m = re.match(r'^\s*download_directory\s*=\s*(.+)$', line, re.IGNORECASE) + if not m: + continue + raw = m.group(1).strip() + # Expect Z:\\path\\... or D:\\path\\... (MO2 doubles backslashes in the file) + drive_m = re.match(r'^([ZzDd]):(.+)$', raw) + if not drive_m: + continue + drive, rest = drive_m.group(1).upper(), drive_m.group(2) + # Collapse doubled backslashes back to single separators + rest = re.sub(r'\\\\', '/', rest).replace('\\', '/') + if drive == 'Z': + return '/' + rest.lstrip('/') + # D: (SD card) - return as-is with leading slash; caller handles sdcard prefix + return '/' + rest.lstrip('/') + return None diff --git a/jackify/backend/handlers/progress_parser.py b/jackify/backend/handlers/progress_parser.py index f00f9eb..ce08273 100644 --- a/jackify/backend/handlers/progress_parser.py +++ b/jackify/backend/handlers/progress_parser.py @@ -90,7 +90,7 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres # Alternative format: "[timestamp] StatusText (current/total) - speed [- Xunit remaining]" # Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s" # Example (engine 0.4.8+): "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s - 23.1GB remaining" - # Timestamp prefix is now optional — engine no longer emits [HH:MM:SS]. + # Timestamp prefix is now optional - engine no longer emits [HH:MM:SS]. self.timestamp_status_pattern = re.compile( r'(?:\[[^\]]+\]\s+)?(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)(?:\s*-\s*([\d.]+)\s*(B|KB|MB|GB|TB)\s+remaining)?', re.IGNORECASE @@ -157,10 +157,17 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres ParsedLine with extracted information """ result = ParsedLine(message=line.strip()) - + if not line.strip(): return result - + + # Suppress internal engine lines that are not user-facing + _suppress_prefixes = ( + "Refreshing OAuth Token", + ) + if any(line.strip().startswith(p) for p in _suppress_prefixes): + return ParsedLine() + # Try to extract phase information phase_info = self._extract_phase(line) if phase_info: diff --git a/jackify/backend/handlers/progress_parser_phase.py b/jackify/backend/handlers/progress_parser_phase.py index 9cc6a80..0734747 100644 --- a/jackify/backend/handlers/progress_parser_phase.py +++ b/jackify/backend/handlers/progress_parser_phase.py @@ -20,11 +20,11 @@ class ProgressParserPhaseMixin: phase = self._map_section_to_phase(section_name) return (phase, section_match.group(1).strip()) - # [FILE_PROGRESS] lines drive file activity only — skip phase extraction for them + # [FILE_PROGRESS] lines drive file activity only - skip phase extraction for them if '[FILE_PROGRESS]' in line: return None - # Make the [timestamp] prefix optional — engine no longer emits it. + # Make the [timestamp] prefix optional - engine no longer emits it. action_match = re.search( r'(?:\[.*?\]\s*)?(Installing|Downloading|Extracting|Validating|Processing|Checking existing)', line, diff --git a/jackify/backend/handlers/protontricks_commands.py b/jackify/backend/handlers/protontricks_commands.py index 67d8ed0..62409ec 100644 --- a/jackify/backend/handlers/protontricks_commands.py +++ b/jackify/backend/handlers/protontricks_commands.py @@ -87,7 +87,7 @@ class ProtontricksCommandsMixin: env['WINETRICKS'] = str(winetricks_path) self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}") else: - self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks") + self.logger.info("Bundled winetricks not found - native protontricks will use system winetricks") cabextract_path = self._get_bundled_cabextract_path() if cabextract_path: cabextract_dir = str(cabextract_path.parent) @@ -95,7 +95,7 @@ class ProtontricksCommandsMixin: env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}") else: - self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract") + self.logger.info("Bundled cabextract not found - native protontricks will use system cabextract") else: self.logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)") diff --git a/jackify/backend/handlers/protontricks_prefix.py b/jackify/backend/handlers/protontricks_prefix.py index 6fe9dd3..806c77c 100644 --- a/jackify/backend/handlers/protontricks_prefix.py +++ b/jackify/backend/handlers/protontricks_prefix.py @@ -74,7 +74,7 @@ class ProtontricksPrefixMixin: self.logger.debug("ShowDotFiles already present in correct format in user.reg") dotfiles_set_success = True else: - self.logger.warning(f"user.reg not found at {user_reg_path}, creating it.") + self.logger.info(f"user.reg not found at {user_reg_path}, creating it.") with open(user_reg_path, 'w', encoding='utf-8') as f: f.write('[Software\\\\Wine] 1603891765\n') f.write('"ShowDotFiles"="Y"\n') @@ -157,6 +157,10 @@ class ProtontricksPrefixMixin: self.logger.info("=" * 80) env = self._get_clean_subprocess_env() env["WINEDEBUG"] = "-all" + # Preserve the desktop display variables for Step 4. The validated fix + # for the blank taskbar popup regression was keeping DISPLAY available. + # Do not strip extra desktop activation vars here without a reproduced, + # evidence-backed need. if self.which_protontricks == 'native': winetricks_path = self._get_bundled_winetricks_path() @@ -164,7 +168,7 @@ class ProtontricksPrefixMixin: env['WINETRICKS'] = str(winetricks_path) self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}") else: - self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks") + self.logger.info("Bundled winetricks not found - native protontricks will use system winetricks") cabextract_path = self._get_bundled_cabextract_path() if cabextract_path: cabextract_dir = str(cabextract_path.parent) @@ -172,7 +176,7 @@ class ProtontricksPrefixMixin: env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}") else: - self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract") + self.logger.info("Bundled cabextract not found - native protontricks will use system cabextract") else: self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)") diff --git a/jackify/backend/handlers/protontricks_steam.py b/jackify/backend/handlers/protontricks_steam.py index 3ebef90..1bb225c 100644 --- a/jackify/backend/handlers/protontricks_steam.py +++ b/jackify/backend/handlers/protontricks_steam.py @@ -81,7 +81,7 @@ class ProtontricksSteamMixin: self.logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}") if steamdeck: - self.logger.warning("Checking for SDCard and setting permissions appropriately...") + self.logger.info("Checking for SDCard and setting permissions appropriately...") result = subprocess.run(["df", "-h"], capture_output=True, text=True, env=env) for line in result.stdout.splitlines(): if "/run/media" in line: diff --git a/jackify/backend/handlers/shortcut_creation.py b/jackify/backend/handlers/shortcut_creation.py index f0ab5f1..ea6b4fe 100644 --- a/jackify/backend/handlers/shortcut_creation.py +++ b/jackify/backend/handlers/shortcut_creation.py @@ -104,12 +104,16 @@ class ShortcutCreationMixin: except Exception as e: self.logger.error(f"Error determining STEAM_COMPAT_MOUNTS: {e}", exc_info=True) + dotnet_vars = 'DOTNET_ROOT="" DOTNET_MULTILEVEL_LOOKUP=0' + final_launch_options = launch_options - if compat_mounts_str: - if final_launch_options: - final_launch_options = f"{compat_mounts_str} {final_launch_options}" - else: - final_launch_options = compat_mounts_str + env_prefix_parts = [p for p in [compat_mounts_str, dotnet_vars] if p] + if env_prefix_parts: + prefix = " ".join(env_prefix_parts) + if final_launch_options: + final_launch_options = f"{prefix} {final_launch_options}" + else: + final_launch_options = prefix if not final_launch_options.strip().endswith("%command%"): if final_launch_options: @@ -138,7 +142,6 @@ class ShortcutCreationMixin: except Exception as e: self.logger.error(f"Error creating shortcut: {e}", exc_info=True) - print(f"An error occurred while creating the shortcut: {e}") return False, None def _is_steam_deck(self): diff --git a/jackify/backend/handlers/shortcut_discovery.py b/jackify/backend/handlers/shortcut_discovery.py index 32d5b16..4f6c66e 100644 --- a/jackify/backend/handlers/shortcut_discovery.py +++ b/jackify/backend/handlers/shortcut_discovery.py @@ -165,7 +165,7 @@ class ShortcutDiscoveryMixin: self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)") return str(int(appid) & 0xFFFFFFFF) - self.logger.warning(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'") + self.logger.debug(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'") return None except Exception as e: diff --git a/jackify/backend/handlers/shortcut_vdf_management.py b/jackify/backend/handlers/shortcut_vdf_management.py index a742267..3204ec2 100644 --- a/jackify/backend/handlers/shortcut_vdf_management.py +++ b/jackify/backend/handlers/shortcut_vdf_management.py @@ -300,7 +300,6 @@ class ShortcutVDFManagementMixin: try: shutil.copy2(safe_backup, shortcuts_file) self.logger.info(f"Restored shortcuts.vdf from pre-restart backup") - print("Restored shortcuts file after Steam restart") return except Exception as e: self.logger.error(f"Failed to restore from pre-restart backup: {e}") @@ -310,9 +309,8 @@ class ShortcutVDFManagementMixin: try: shutil.copy2(backup, shortcuts_file) self.logger.info(f"Restored shortcuts.vdf from regular backup") - print("Restored shortcuts file after Steam restart") except Exception as e: self.logger.error(f"Failed to restore from backup: {e}") - print("Failed to restore shortcuts file. You may need to recreate your shortcut.") + self.logger.warning("shortcuts.vdf restore failed - shortcut may need to be recreated") else: self.logger.info(f"shortcuts.vdf verified intact after restart") diff --git a/jackify/backend/handlers/subprocess_utils.py b/jackify/backend/handlers/subprocess_utils.py index 73266e8..542f67e 100644 --- a/jackify/backend/handlers/subprocess_utils.py +++ b/jackify/backend/handlers/subprocess_utils.py @@ -266,7 +266,7 @@ class ProcessManager: pass cleanup_attempts += 1 finally: - # Always close pipes — unblocks threads blocked on read(1) or iterating stderr + # Always close pipes - unblocks threads blocked on read(1) or iterating stderr if self.proc: for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr): if pipe: diff --git a/jackify/backend/handlers/ttw_installer_backend.py b/jackify/backend/handlers/ttw_installer_backend.py index aa06713..924b6b9 100644 --- a/jackify/backend/handlers/ttw_installer_backend.py +++ b/jackify/backend/handlers/ttw_installer_backend.py @@ -279,7 +279,7 @@ class TTWInstallerBackendMixin: mod_folder_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}" if ttw_version else "[NoDelete] Tale of Two Wastelands" target_mod_dir = mods_dir / mod_folder_name if skip_copy: - # TTW was installed directly to target_mod_dir — no copy needed + # TTW was installed directly to target_mod_dir - no copy needed logger.info("TTW already at target location, skipping copy: %s", target_mod_dir) else: logger.info("Copying TTW output to %s", target_mod_dir) diff --git a/jackify/backend/handlers/wabbajack_parser.py b/jackify/backend/handlers/wabbajack_parser.py index 6f261d3..fc31bca 100644 --- a/jackify/backend/handlers/wabbajack_parser.py +++ b/jackify/backend/handlers/wabbajack_parser.py @@ -26,18 +26,21 @@ class WabbajackParser: 'Fallout4': 'fallout4', 'FalloutNewVegas': 'falloutnv', 'Oblivion': 'oblivion', - 'Skyrim': 'skyrim', # Legacy Skyrim - 'Fallout3': 'fallout3', # For completeness - 'SkyrimVR': 'skyrim', # Treat as Skyrim - 'Fallout4VR': 'fallout4', # Treat as Fallout 4 - 'Enderal': 'enderal', # Enderal: Forgotten Stories - 'EnderalSpecialEdition': 'enderal', # Enderal SE + 'Skyrim': 'skyrim', + 'Fallout3': 'fallout3', + 'SkyrimVR': 'skyrimvr', + 'Fallout4VR': 'fallout4vr', + 'Enderal': 'enderal', + 'EnderalSpecialEdition': 'enderal', + 'Cyberpunk2077': 'cp2077', + 'BaldursGate3': 'bg3', } - + # List of supported games in Jackify self.supported_games = [ 'skyrim', 'fallout4', 'falloutnv', 'fallout3', 'oblivion', - 'starfield', 'oblivion_remastered', 'enderal' + 'starfield', 'oblivion_remastered', 'enderal', + 'skyrimvr', 'fallout4vr', 'bg3', ] def parse_wabbajack_game_type(self, wabbajack_path: Path) -> Optional[tuple]: @@ -98,6 +101,23 @@ class WabbajackParser: self.logger.error(f"Error parsing .wabbajack file {wabbajack_path}: {e}") return None + def parse_wabbajack_readme(self, wabbajack_path: Path) -> Optional[str]: + """ + Extract the readme URL from a .wabbajack file. + + Returns the URL string, or None if not present or unreadable. + """ + try: + with zipfile.ZipFile(wabbajack_path, 'r') as zip_file: + modlist_files = [f for f in zip_file.namelist() if f in ['modlist', 'modlist.json']] + if not modlist_files: + return None + with zip_file.open(modlist_files[0]) as f: + data = json.load(f) + return data.get('Readme') or None + except Exception: + return None + def is_supported_game(self, game_type: str) -> bool: """ Check if a game type is supported by Jackify's post-install configuration. @@ -128,12 +148,16 @@ class WabbajackParser: """ display_names = { 'skyrim': 'Skyrim Special Edition', - 'fallout4': 'Fallout 4', + 'fallout4': 'Fallout 4', 'falloutnv': 'Fallout New Vegas', 'oblivion': 'Oblivion', 'starfield': 'Starfield', 'oblivion_remastered': 'Oblivion Remastered', - 'enderal': 'Enderal' + 'enderal': 'Enderal', + 'skyrimvr': 'Skyrim VR', + 'fallout4vr': 'Fallout 4 VR', + 'cp2077': 'Cyberpunk 2077', + 'bg3': "Baldur's Gate 3", } return [display_names.get(game, game) for game in self.supported_games] diff --git a/jackify/backend/handlers/wine_utils_config.py b/jackify/backend/handlers/wine_utils_config.py index d839fc2..8bc4638 100644 --- a/jackify/backend/handlers/wine_utils_config.py +++ b/jackify/backend/handlers/wine_utils_config.py @@ -6,7 +6,6 @@ Extracted from wine_utils for file-size and domain separation. """ import os -import re import subprocess import logging from typing import Optional @@ -56,39 +55,6 @@ class WineUtilsConfigMixin: logger.error(f"Error performing additional tasks: {e}") return False - @staticmethod - def modlist_specific_steps(modlist: str, appid: str) -> bool: - """Perform modlist-specific configuration steps. Returns True on success.""" - try: - modlist_configs = { - "wildlander": ["dotnet48", "dotnet472", "vcrun2019"], - "septimus|sigernacollection|licentia|aldrnari|phoenix": ["dotnet48", "dotnet472"], - "masterstroke": ["dotnet48", "dotnet472"], - "diablo": ["dotnet48", "dotnet472"], - "living_skyrim": ["dotnet48", "dotnet472", "dotnet462"], - "nolvus": ["dotnet8"] - } - modlist_lower = modlist.lower().replace(" ", "") - if "wildlander" in modlist_lower: - logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!") - return True - for pattern, components in modlist_configs.items(): - if re.search(pattern.replace("|", "|.*"), modlist_lower): - logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!") - for component in components: - if component == "dotnet8": - logger.info("Downloading .NET 8 Runtime") - pass - else: - logger.info(f"Installing {component}...") - pass - return True - logger.debug(f"No specific steps needed for {modlist}") - return True - except Exception as e: - logger.error(f"Error performing modlist-specific steps: {e}") - return False - @staticmethod def fnv_launch_options(game_var: str, compat_data_path: Optional[str], modlist: str) -> bool: """Set up Fallout New Vegas launch options. Returns True on success.""" diff --git a/jackify/backend/handlers/wine_utils_proton.py b/jackify/backend/handlers/wine_utils_proton.py index f5065bc..5a6fe19 100644 --- a/jackify/backend/handlers/wine_utils_proton.py +++ b/jackify/backend/handlers/wine_utils_proton.py @@ -136,7 +136,7 @@ class WineUtilsProtonMixin: if fallback_path != 'auto': fallback_wine_bin = Path(fallback_path) / "files/bin/wine" if fallback_wine_bin.is_file(): - logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.") + logger.info(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.") return str(fallback_wine_bin) except Exception: pass diff --git a/jackify/backend/handlers/winetricks_env.py b/jackify/backend/handlers/winetricks_env.py index a755c64..aeb0f87 100644 --- a/jackify/backend/handlers/winetricks_env.py +++ b/jackify/backend/handlers/winetricks_env.py @@ -36,7 +36,6 @@ def _get_clean_winetricks_base_env() -> dict: env["PATH"] = path or "/usr/bin:/bin" return env - class WinetricksEnvMixin: """Mixin providing env build and dependency check for WinetricksHandler.install_wine_components.""" @@ -54,10 +53,11 @@ class WinetricksEnvMixin: env['WINEDEBUG'] = '-all' env['WINEPREFIX'] = wineprefix env['WINETRICKS_GUI'] = 'none' - if 'DISPLAY' in env: - env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d' - else: - env['DISPLAY'] = env.get('DISPLAY', '') + env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d' + # Preserve the desktop display variables for Step 4. The validated fix + # for the blank taskbar popup regression was keeping DISPLAY available. + # Do not strip extra desktop activation vars here without a reproduced, + # evidence-backed need. try: from ..handlers.config_handler import ConfigHandler @@ -243,7 +243,10 @@ class WinetricksEnvMixin: if not found: missing_deps.append(dep_name) if dep_name in bundled_tools_list: - self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)") + if dep_name == 'aria2c': + self.logger.debug(f" {dep_name}: NOT FOUND (optional - curl/wget used if available)") + else: + self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)") else: self.logger.warning(f" {dep_name}: NOT FOUND (system only - not bundled)") diff --git a/jackify/backend/models/modlist.py b/jackify/backend/models/modlist.py index 3d2acb5..558e550 100644 --- a/jackify/backend/models/modlist.py +++ b/jackify/backend/models/modlist.py @@ -14,9 +14,9 @@ class ModlistContext: """Context object for modlist operations.""" name: str install_dir: Path - download_dir: Path game_type: str nexus_api_key: str + download_dir: Optional[Path] = None modlist_value: Optional[str] = None modlist_source: Optional[str] = None # 'identifier' or 'file' resolution: Optional[str] = None @@ -29,8 +29,8 @@ class ModlistContext: """Convert string paths to Path objects.""" if isinstance(self.install_dir, str): self.install_dir = Path(self.install_dir) - if isinstance(self.download_dir, str): - self.download_dir = Path(self.download_dir) + if self.download_dir is not None and isinstance(self.download_dir, str): + self.download_dir = Path(self.download_dir) if self.download_dir else None if isinstance(self.mo2_exe_path, str): self.mo2_exe_path = Path(self.mo2_exe_path) diff --git a/jackify/backend/services/automated_prefix_game_utils.py b/jackify/backend/services/automated_prefix_game_utils.py index 2d728f2..53c7abe 100644 --- a/jackify/backend/services/automated_prefix_game_utils.py +++ b/jackify/backend/services/automated_prefix_game_utils.py @@ -19,66 +19,68 @@ logger = logging.getLogger(__name__) class GameUtilsMixin: """Mixin for game-related utility operations""" - 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 + # 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""" @@ -140,36 +142,90 @@ class GameUtilsMixin: return None - def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str): + def _detect_skyrim_se_modlist(self, modlist_dir: str) -> bool: + """ + Return True if modlist_dir is a Skyrim SE (non-VR) modlist. + + Used only to trigger first-launch seeding when special_game_type is None. + Other games are not yet confirmed to need this treatment. + """ + if not modlist_dir: + return False + try: + mo2_ini = Path(modlist_dir) / "ModOrganizer.ini" + if not mo2_ini.exists(): + mo2_ini = Path(modlist_dir) / "files" / "ModOrganizer.ini" + if not mo2_ini.exists(): + return False + content = mo2_ini.read_text(errors='ignore').lower() + # Anchor VR check to gameName= to avoid false positives from plugin + # setting keys like enable_skyrimVR=false appearing in SE modlists. + for _line in content.splitlines(): + if _line.strip().startswith("gamename="): + game_name_value = _line.strip()[len("gamename="):] + if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value: + return False + break + return 'skyrim special edition' in content or 'skse64_loader' in content + except Exception as e: + logger.debug(f"Could not check Skyrim SE detection for {modlist_dir}: {e}") + return False + + def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str, + modlist_dir: Optional[str] = None): """ Pre-create game-specific user directories to prevent first-launch issues. Creates both My Documents/My Games and AppData/Local directories for the game. - This prevents issues where games fail to create these on first launch under Proton. + special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games). For standard + games like Skyrim SE that aren't "special" in that sense, modlist_dir is used to + detect what directories to seed. """ - # Map game types to their directory names + # Bethesda-pattern games: same name used for both My Games and AppData/Local game_dir_names = { "skyrim": "Skyrim Special Edition", + "skyrimvr": "Skyrim VR", "fnv": "FalloutNV", "fo3": "Fallout3", "fo4": "Fallout4", + "fallout4vr": "Fallout4VR", "oblivion": "Oblivion", "oblivion_remastered": "Oblivion Remastered", "enderal": "Enderal Special Edition", - "starfield": "Starfield" + "starfield": "Starfield", } - # Get the directory name for this game type - game_dir_name = game_dir_names.get(special_game_type) - if not game_dir_name: - logger.debug(f"No user directory mapping for game type: {special_game_type}") - return + # Non-Bethesda games: AppData/Local only, with a vendor-namespaced subdirectory + game_appdata_only = { + "cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"), + "bg3": os.path.join("Larian Studios", "Baldur's Gate 3"), + } + + # special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games). + # Skyrim SE returns None from detect_special_game_type but still needs seeding. + game_type = special_game_type + if special_game_type is None and modlist_dir and self._detect_skyrim_se_modlist(modlist_dir): + game_type = "skyrim" base_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser") + if game_type in game_appdata_only: + appdata_dir = os.path.join(base_path, "AppData", "Local", game_appdata_only[game_type]) + try: + os.makedirs(appdata_dir, exist_ok=True) + logger.info(f"Created AppData/Local directory: {appdata_dir}") + except Exception as e: + logger.warning(f"Failed to create AppData/Local directory {appdata_dir}: {e}") + return + + game_dir_name = game_dir_names.get(game_type) + if not game_dir_name: + logger.debug(f"No user directory mapping for game type: {game_type}") + return + directories_to_create = [ os.path.join(base_path, "Documents", "My Games", game_dir_name), - os.path.join(base_path, "AppData", "Local", game_dir_name) + os.path.join(base_path, "AppData", "Local", game_dir_name), ] created_count = 0 @@ -184,90 +240,46 @@ class GameUtilsMixin: if created_count > 0: logger.info(f"Created {created_count} user directories for {game_dir_name}") - def _get_lorerim_preferred_proton(self): - """Get Lorerim's preferred Proton 9 version with specific priority order""" + if game_type == "skyrim": + self._seed_skyrim_first_launch_files(base_path, game_dir_name) + elif game_type == "fo4": + self._seed_fo4_first_launch_files(base_path, game_dir_name) + elif game_type == "skyrimvr": + self._seed_skyrimvr_first_launch_files(base_path, game_dir_name) + elif game_type == "fallout4vr": + self._seed_fallout4vr_first_launch_files(base_path, game_dir_name) + def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: + """Delegate to FileSystemHandler to seed Skyrim first-launch fix files.""" try: - from jackify.backend.handlers.wine_utils import WineUtils - - # Get all available Proton versions - available_versions = WineUtils.scan_all_proton_versions() - - if not available_versions: - logger.warning("No Proton versions found for Lorerim override") - return None - - # Priority order for Lorerim: - # 1. GEProton9-27 (specific version) - # 2. Other GEProton-9 versions (latest first) - # 3. Valve Proton 9 (any version) - - preferred_candidates = [] - - for version in available_versions: - version_name = version['name'] - - # Priority 1: GEProton9-27 specifically - if version_name == 'GE-Proton9-27': - logger.info(f"Lorerim: Found preferred GE-Proton9-27") - return version_name - - # Priority 2: Other GE-Proton 9 versions - elif version_name.startswith('GE-Proton9-'): - preferred_candidates.append(('ge_proton_9', version_name, version)) - - # Priority 3: Valve Proton 9 - elif 'Proton 9' in version_name: - preferred_candidates.append(('valve_proton_9', version_name, version)) - - # Return best candidate if any found - if preferred_candidates: - # Sort by priority (GE-Proton first, then by name for latest) - preferred_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True) - best_candidate = preferred_candidates[0] - logger.info(f"Lorerim: Selected {best_candidate[1]} as best Proton 9 option") - return best_candidate[1] - - logger.warning("Lorerim: No suitable Proton 9 versions found, will use user settings") - return None - + from jackify.backend.handlers.filesystem_handler import FileSystemHandler + fsh = FileSystemHandler() + fsh._seed_skyrim_first_launch_files(prefix_user, docs_dir_name) except Exception as e: - logger.error(f"Error detecting Lorerim Proton preference: {e}") - return None + logger.warning(f"Could not seed Skyrim first-launch files: {e}") - def _store_proton_override_notification(self, modlist_name: str, proton_version: str): - """Store Proton override information for end-of-install notification""" + def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: + """Delegate to FileSystemHandler to seed Fallout 4 first-launch fix files.""" try: - # Store override info for later display - if not hasattr(self, '_proton_overrides'): - self._proton_overrides = [] - - self._proton_overrides.append({ - 'modlist': modlist_name, - 'proton_version': proton_version, - 'reason': f'{modlist_name} requires Proton 9 for optimal compatibility' - }) - - logger.debug(f"Stored Proton override notification: {modlist_name} → {proton_version}") - + from jackify.backend.handlers.filesystem_handler import FileSystemHandler + fsh = FileSystemHandler() + fsh._seed_fo4_first_launch_files(prefix_user, docs_dir_name) except Exception as e: - logger.error(f"Failed to store Proton override notification: {e}") + logger.warning(f"Could not seed FO4 first-launch files: {e}") - def _show_proton_override_notification(self, progress_callback=None): - """Display any Proton override notifications to the user""" + def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: + """Delegate to FileSystemHandler to seed Skyrim VR first-launch fix files.""" try: - if hasattr(self, '_proton_overrides') and self._proton_overrides: - for override in self._proton_overrides: - notification_msg = f"PROTON OVERRIDE: {override['modlist']} configured to use {override['proton_version']} for optimal compatibility" - - if progress_callback: - progress_callback("") - progress_callback(f"{self._get_progress_timestamp()} {notification_msg}") - - logger.info(notification_msg) - - # Clear notifications after display - self._proton_overrides = [] - + from jackify.backend.handlers.filesystem_handler import FileSystemHandler + fsh = FileSystemHandler() + fsh._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name) except Exception as e: - logger.error(f"Failed to show Proton override notification: {e}") + logger.warning(f"Could not seed SkyrimVR first-launch files: {e}") + def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: + """Delegate to FileSystemHandler to seed Fallout 4 VR first-launch fix files.""" + try: + from jackify.backend.handlers.filesystem_handler import FileSystemHandler + fsh = FileSystemHandler() + fsh._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name) + except Exception as e: + logger.warning(f"Could not seed FO4VR first-launch files: {e}") diff --git a/jackify/backend/services/automated_prefix_proton.py b/jackify/backend/services/automated_prefix_proton.py index be95843..384dd06 100644 --- a/jackify/backend/services/automated_prefix_proton.py +++ b/jackify/backend/services/automated_prefix_proton.py @@ -20,23 +20,6 @@ class ProtonOperationsMixin: from jackify.backend.handlers.config_handler import ConfigHandler from jackify.backend.handlers.wine_utils import WineUtils - # Check for Lorerim-specific Proton override first - modlist_normalized = modlist_name.lower().replace(" ", "") if modlist_name else "" - if modlist_normalized == 'lorerim': - lorerim_proton = self._get_lorerim_preferred_proton() - if lorerim_proton: - logger.info(f"Lorerim detected: Using {lorerim_proton} instead of user settings") - self._store_proton_override_notification("Lorerim", lorerim_proton) - return lorerim_proton - - # Check for Lost Legacy-specific Proton override (needs Proton 9 for ENB compatibility) - if modlist_normalized == 'lostlegacy': - lostlegacy_proton = self._get_lorerim_preferred_proton() # Use same logic as Lorerim - if lostlegacy_proton: - logger.info(f"Lost Legacy detected: Using {lostlegacy_proton} instead of user settings (ENB compatibility)") - self._store_proton_override_notification("Lost Legacy", lostlegacy_proton) - return lostlegacy_proton - config_handler = ConfigHandler() user_proton_path = config_handler.get_game_proton_path() diff --git a/jackify/backend/services/automated_prefix_registry.py b/jackify/backend/services/automated_prefix_registry.py index eb8c0fe..eb56b1f 100644 --- a/jackify/backend/services/automated_prefix_registry.py +++ b/jackify/backend/services/automated_prefix_registry.py @@ -1,5 +1,6 @@ """Registry operations mixin for AutomatedPrefixService.""" import os +import re import subprocess import logging from pathlib import Path @@ -74,7 +75,7 @@ class RegistryOperationsMixin: def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str): """Apply universal dotnet4.x compatibility registry fixes to ALL modlists. - Direct file editing is preferred over `wine reg add` — faster, no Wine + Direct file editing is preferred over `wine reg add` - faster, no Wine process overhead, and works even when Proton isn't on PATH. Falls back to subprocess wine reg add when the reg files haven't been created yet. """ @@ -91,10 +92,12 @@ class RegistryOperationsMixin: fix1 = fix2 = False + # Targeted per-exe override for SkyrimSE.exe only - see modlist_wine_ops.py + # for rationale. Global DllOverrides entry breaks .NET 9/10 bootstrap. if os.path.exists(user_reg): fix1 = self._reg_set_value( user_reg, - "[Software\\\\Wine\\\\DllOverrides]", + "[Software\\\\Wine\\\\AppDefaults\\\\SkyrimSE.exe\\\\DllOverrides]", '"*mscoree"', '"native"', ) @@ -123,7 +126,7 @@ class RegistryOperationsMixin: r1 = subprocess.run( [wine_binary, 'reg', 'add', - 'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides', + 'HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides', '/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'], env=env, capture_output=True, text=True, errors='replace', ) @@ -145,6 +148,53 @@ class RegistryOperationsMixin: logger.error(f"Failed to apply universal dotnet4.x fixes: {e}") return False + def _apply_cp2077_dll_overrides(self, modlist_compatdata_path: str) -> bool: + """Write CP2077 DLL overrides directly into the prefix user.reg. + + MO2 on Linux launches each executable through a separate Proton invocation, + so WINEDLLOVERRIDES set in Steam launch options is not inherited by the game + process. Writing the overrides into user.reg ensures they are always applied + regardless of how the process is started. + + version and winmm are the entry-point DLLs for CET and Red4ext respectively. + Without native,builtin for both, neither mod framework can inject into the + game process and CP2077 exits immediately. + """ + try: + user_reg = os.path.join(modlist_compatdata_path, "pfx", "user.reg") + if not os.path.exists(user_reg): + logger.warning("user.reg not found, cannot apply CP2077 DLL overrides") + return False + + section = "[Software\\\\Wine\\\\DllOverrides]" + overrides = [ + ('"version"', '"native,builtin"'), + ('"winmm"', '"native,builtin"'), + ] + for key, val in overrides: + self._reg_set_value(user_reg, section, key, val) + + logger.info("Applied CP2077 DLL overrides (version, winmm) to prefix registry") + return True + except Exception as e: + logger.error(f"Failed to apply CP2077 DLL overrides: {e}") + return False + + @staticmethod + def _wow64_counterpart(section: str) -> str: + """Return the Wow6432Node counterpart for a registry section, or vice versa. + + NaK writes both paths for every game so both 32-bit and 64-bit lookups + resolve correctly regardless of the calling process's bitness. + """ + low = section.lower() + if "wow6432node" in low: + # Strip Wow6432Node to get the 64-bit path + return re.sub(r'(?i)wow6432node\\\\', '', section) + else: + # Insert Wow6432Node after the opening [Software\\ + return re.sub(r'(?i)(\[Software\\\\)', r'\1Wow6432Node\\\\', section) + def _reg_set_value(self, reg_path: str, section: str, key: str, value: str) -> bool: """Set or add a key=value pair in a Wine .reg text file.""" try: @@ -319,19 +369,19 @@ class RegistryOperationsMixin: "name": "Fallout New Vegas", "common_names": ["Fallout New Vegas", "FalloutNV"], "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]", - "path_key": "Installed Path", + "path_key": "installed path", }, "22300": { # Fallout 3 AppID "name": "Fallout 3", "common_names": ["Fallout 3", "Fallout3", "Fallout 3 GOTY"], "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]", - "path_key": "Installed Path", + "path_key": "installed path", }, "22370": { # Fallout 3 GOTY AppID alias "name": "Fallout 3", "common_names": ["Fallout 3 GOTY", "Fallout 3"], "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]", - "path_key": "Installed Path", + "path_key": "installed path", }, "976620": { # Enderal Special Edition AppID "name": "Enderal", @@ -339,6 +389,72 @@ class RegistryOperationsMixin: "registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]", "path_key": "installed path", }, + "1091500": { # Cyberpunk 2077 AppID + "name": "Cyberpunk 2077", + "common_names": ["Cyberpunk 2077"], + "registry_section": "[Software\\\\CD Projekt Red\\\\Cyberpunk 2077]", + "path_key": "InstallFolder", + }, + "1086940": { # Baldur's Gate 3 AppID + "name": "Baldur's Gate 3", + "common_names": ["Baldur's Gate 3", "BaldursGate3"], + "registry_section": "[Software\\\\Larian Studios\\\\Baldur's Gate 3]", + "path_key": "InstallDir", + }, + "611670": { # Skyrim VR AppID (64-bit, no Wow6432Node) + "name": "Skyrim VR", + "common_names": ["Skyrim VR", "SkyrimVR"], + "registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim VR]", + "path_key": "Installed Path", + }, + "611660": { # Fallout 4 VR AppID (64-bit, no Wow6432Node) + "name": "Fallout 4 VR", + "common_names": ["Fallout 4 VR", "Fallout4VR"], + "registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout 4 VR]", + "path_key": "Installed Path", + }, + "22330": { # Oblivion AppID + "name": "Oblivion", + "common_names": ["Oblivion", "Elder Scrolls IV Oblivion"], + "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\oblivion]", + "path_key": "installed path", + }, + "1716740": { # Starfield AppID (64-bit, no Wow6432Node) + "name": "Starfield", + "common_names": ["Starfield"], + "registry_section": "[Software\\\\Bethesda Softworks\\\\Starfield]", + "path_key": "Installed Path", + }, + "489830": { # Skyrim Special Edition AppID (64-bit, no Wow6432Node) + "name": "Skyrim Special Edition", + "common_names": ["Skyrim Special Edition", "SkyrimSE", "Skyrim Anniversary Edition"], + "registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim Special Edition]", + "path_key": "Installed Path", + }, + "377160": { # Fallout 4 AppID (64-bit, no Wow6432Node) + "name": "Fallout 4", + "common_names": ["Fallout 4", "Fallout4"], + "registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout4]", + "path_key": "Installed Path", + }, + "22320": { # Morrowind AppID (32-bit, Wow6432Node) + "name": "Morrowind", + "common_names": ["Morrowind", "Elder Scrolls III Morrowind"], + "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\morrowind]", + "path_key": "Installed Path", + }, + "292030": { # The Witcher 3 AppID (64-bit, no Wow6432Node) + "name": "The Witcher 3", + "common_names": ["The Witcher 3", "Witcher 3", "The Witcher 3 Wild Hunt"], + "registry_section": "[Software\\\\CD Projekt Red\\\\The Witcher 3]", + "path_key": "InstallFolder", + }, + "2623190": { # Oblivion Remastered AppID (64-bit UE5, no Wow6432Node) + "name": "Oblivion Remastered", + "common_names": ["Oblivion Remastered", "OblivionRemastered"], + "registry_section": "[Software\\\\Bethesda Softworks\\\\Oblivion Remastered]", + "path_key": "Installed Path", + }, } pfx_path = Path(modlist_compatdata_path) / "pfx" @@ -359,24 +475,22 @@ class RegistryOperationsMixin: game_dir_name = Path(game_path).name canonical_win_path = f"C:\\Program Files (x86)\\Steam\\steamapps\\common\\{game_dir_name}" wine_val = canonical_win_path.replace("\\", "\\\\") + "\\\\" - success = self._reg_set_value( - system_reg_path, - config["registry_section"], - f'"{config["path_key"]}"', - f'"{wine_val}"', - ) + key = f'"{config["path_key"]}"' + val = f'"{wine_val}"' + success = self._reg_set_value(system_reg_path, config["registry_section"], key, val) + self._reg_set_value(system_reg_path, self._wow64_counterpart(config["registry_section"]), key, val) if success: logger.info(f"Registry set to canonical path for {config['name']}: {canonical_win_path}") else: logger.warning(f"Failed to set canonical registry path for {config['name']}") else: - # Symlink failed — fall back to writing the real Z:/D: path + # Symlink failed - fall back to writing the real Z:/D: path logger.warning(f"Symlink failed for {config['name']}, writing real path to registry") success = self._update_registry_path( - system_reg_path, - config["registry_section"], - config["path_key"], - game_path + system_reg_path, config["registry_section"], config["path_key"], game_path + ) + self._update_registry_path( + system_reg_path, self._wow64_counterpart(config["registry_section"]), config["path_key"], game_path ) if success: logger.info(f"Updated registry entry for {config['name']} (real path fallback)") diff --git a/jackify/backend/services/automated_prefix_shortcuts.py b/jackify/backend/services/automated_prefix_shortcuts.py index c3bdac6..d226a22 100644 --- a/jackify/backend/services/automated_prefix_shortcuts.py +++ b/jackify/backend/services/automated_prefix_shortcuts.py @@ -38,25 +38,29 @@ class ShortcutOperationsMixin(AutomatedPrefixShortcutsCleanupMixin): # Initialize native Steam service steam_service = NativeSteamService() - # Use custom launch options if provided, otherwise generate default + # Always compute STEAM_COMPAT_MOUNTS; custom_launch_options replaces %command% but + # still needs mounts so game assets on other drives are reachable inside the prefix. + mounts_prefix = "" + try: + from ..handlers.path_handler import PathHandler + path_handler = PathHandler() + mount_paths = path_handler.get_steam_compat_mount_paths( + install_dir=modlist_install_dir, download_dir=download_dir + ) + if mount_paths: + mounts_prefix = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}"' + logger.info(f"Generated STEAM_COMPAT_MOUNTS: {mounts_prefix}") + except Exception as e: + logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS: {e}") + if custom_launch_options: - launch_options = custom_launch_options - logger.info(f"Using pre-generated launch options: {launch_options}") + launch_options = f"{mounts_prefix} {custom_launch_options}".strip() if mounts_prefix else custom_launch_options + logger.info(f"Launch options (custom + mounts): {launch_options}") + elif mounts_prefix: + launch_options = f'{mounts_prefix} %command%' + logger.info(f"Launch options (mounts only): {launch_options}") else: - # Generate STEAM_COMPAT_MOUNTS including install and download mountpoints launch_options = "%command%" - try: - from ..handlers.path_handler import PathHandler - path_handler = PathHandler() - mount_paths = path_handler.get_steam_compat_mount_paths( - install_dir=modlist_install_dir, download_dir=download_dir - ) - if mount_paths: - launch_options = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}" %command%' - logger.info(f"Generated launch options with mounts: {launch_options}") - except Exception as e: - logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS, using default: {e}") - launch_options = "%command%" # Get user's preferred Proton version (with Lorerim-specific override) proton_version = self._get_user_proton_version(shortcut_name) diff --git a/jackify/backend/services/automated_prefix_workflow.py b/jackify/backend/services/automated_prefix_workflow.py index 5cec4ed..c59bd29 100644 --- a/jackify/backend/services/automated_prefix_workflow.py +++ b/jackify/backend/services/automated_prefix_workflow.py @@ -178,10 +178,20 @@ class WorkflowMixin: modlist_handler = ModlistHandler() special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir) - # No launch options needed - FNV, FO3 and Enderal use registry injection custom_launch_options = None if special_game_type in ["fnv", "fo3", "enderal"]: logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist") + elif special_game_type == "cp2077": + logger.info("Cyberpunk 2077 modlist detected - setting WINEDLLOVERRIDES for Red4ext/CET") + # version=n,b overrides d3d version detection for REDmod; winmm=n,b required for CET + custom_launch_options = 'WINEDLLOVERRIDES="version=n,b;winmm=n,b" %command%' + elif special_game_type == "bg3": + logger.info("Baldur's Gate 3 modlist detected") + logger.warning("BG3 modlists require Rootbuilder in COPY mode - verify this in MO2 plugin settings") + elif special_game_type in ["skyrimvr", "fallout4vr"]: + game_label = "Skyrim VR" if special_game_type == "skyrimvr" else "Fallout 4 VR" + logger.warning("%s modlist detected - SteamVR must be installed and running for this modlist to work", game_label) + logger.warning("%s modlists use Rootbuilder for game root files - ensure Rootbuilder is set to COPY mode in MO2 plugin settings", game_label) else: logger.debug("Standard modlist - no special game handling needed") @@ -202,6 +212,31 @@ class WorkflowMixin: if progress_callback: progress_callback(f"{self._get_progress_timestamp()} Steam shut down") + # Pre-fetch SteamGridDB artwork before shortcut creation so the icon field in + # shortcuts.vdf is populated at write time. Steam caches the icon on first read + # after restart; setting it after the fact has no effect. + steamicons_dir = Path(modlist_install_dir) / "SteamIcons" + if not steamicons_dir.is_dir(): + from ..services.steamgriddb_service import detect_game_type_from_modlist + _prefetch_game_type = detect_game_type_from_modlist(modlist_install_dir) + if _prefetch_game_type: + try: + from ..services.steamgriddb_service import fetch_artwork + steamicons_dir.mkdir(parents=True, exist_ok=True) + count = fetch_artwork(_prefetch_game_type, steamicons_dir) + if count == 0: + steamicons_dir.rmdir() + logger.debug("SteamGridDB pre-fetch returned no images") + else: + logger.info(f"Pre-fetched {count} SteamGridDB images to {steamicons_dir}") + except Exception as e: + logger.debug(f"SteamGridDB pre-fetch failed: {e}") + try: + if steamicons_dir.is_dir() and not any(steamicons_dir.iterdir()): + steamicons_dir.rmdir() + except Exception: + pass + # Step 1: Create shortcut with native Steam service (Steam is now shut down) logger.info("Step 1: Creating shortcut with native Steam service") # Create shortcut using native Steam service with special game launch options @@ -222,9 +257,9 @@ class WorkflowMixin: from ..handlers.modlist_handler import ModlistHandler modlist_handler = ModlistHandler() modlist_handler.set_steam_grid_images(str(appid), modlist_install_dir) - logger.info(f"Applied Steam artwork for shortcut '{shortcut_name}' (AppID: {appid})") + logger.info(f"Steam artwork applied for shortcut '{shortcut_name}' (AppID: {appid})") except Exception as e: - logger.warning(f"Failed to apply Steam artwork: {e}") + logger.warning(f"Steam artwork application failed: {e}") # Step 2: Start Steam (if auto_restart enabled) logger.info("Step 2: auto_restart=%s", auto_restart) @@ -243,6 +278,7 @@ class WorkflowMixin: logger.info("Step 2 completed: Steam started") if progress_callback: progress_callback(f"{self._get_progress_timestamp()} Steam started successfully") + progress_callback("[Jackify] Steam restart complete") else: logger.info("Step 2 skipped: Auto-restart disabled by user") if progress_callback: @@ -287,6 +323,15 @@ class WorkflowMixin: self._inject_game_registry_entries(str(prefix_path), special_game_type) else: logger.warning("Could not find prefix path for registry injection") + elif special_game_type == "cp2077": + logger.info("Step 5: Applying CP2077 DLL overrides to prefix registry") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Configuring CP2077 mod framework DLL overrides...") + + if prefix_path: + self._apply_cp2077_dll_overrides(str(prefix_path)) + else: + logger.warning("Could not find prefix path for CP2077 DLL override injection") else: logger.info("Step 5: Skipping registry injection for standard modlist") @@ -296,18 +341,18 @@ class WorkflowMixin: progress_callback(f"{self._get_progress_timestamp()} Creating game user directories...") if prefix_path: - self._create_game_user_directories(str(prefix_path), special_game_type) + self._create_game_user_directories(str(prefix_path), special_game_type, modlist_install_dir) else: logger.warning("Could not find prefix path for directory creation") - + + + last_timestamp = self._get_progress_timestamp() logger.info(f" Working workflow completed successfully! AppID: {appid}, Prefix: {prefix_path}") if progress_callback: progress_callback(f"{last_timestamp} Steam integration complete") progress_callback("") # Blank line after Steam integration complete - # Show Proton override notification if applicable - self._show_proton_override_notification(progress_callback) if progress_callback: progress_callback("") # Extra blank line to span across Configuration Summary diff --git a/jackify/backend/services/file_validator_service.py b/jackify/backend/services/file_validator_service.py index 9436deb..e4a48a4 100644 --- a/jackify/backend/services/file_validator_service.py +++ b/jackify/backend/services/file_validator_service.py @@ -221,7 +221,7 @@ class FileValidatorService: def _validate(self, file_path: Path, expected_hash: str) -> ValidationResult: try: - # No expected hash — accept by filename match alone, just move the file. + # No expected hash - accept by filename match alone, just move the file. if not (expected_hash or "").strip(): return ValidationResult(matches=True, computed_hash=None, file_path=file_path) h = xxhash.xxh64() if xxhash else _XXH64Fallback() diff --git a/jackify/backend/services/manual_download_manager.py b/jackify/backend/services/manual_download_manager.py index 1eba569..687e994 100644 --- a/jackify/backend/services/manual_download_manager.py +++ b/jackify/backend/services/manual_download_manager.py @@ -22,7 +22,9 @@ logger = logging.getLogger(__name__) STATUS = Literal["pending", "browser_opened", "validating", "complete", "deferred", "skipped", "error"] -_STATE_FILE = Path.home() / '.local' / 'share' / 'jackify' / 'manual_download_state.json' +def _get_state_file() -> Path: + from jackify.shared.paths import get_jackify_data_dir + return get_jackify_data_dir() / 'manual_download_state.json' @dataclass diff --git a/jackify/backend/services/manual_download_manager_runtime_mixin.py b/jackify/backend/services/manual_download_manager_runtime_mixin.py index 7ee02f3..2e9cc1a 100644 --- a/jackify/backend/services/manual_download_manager_runtime_mixin.py +++ b/jackify/backend/services/manual_download_manager_runtime_mixin.py @@ -224,7 +224,7 @@ class ManualDownloadManagerRuntimeMixin: item_to_notify = item completed_now = True else: - # Hash mismatch or validation error — revert to pending so the + # 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' diff --git a/jackify/backend/services/modlist_gallery_service.py b/jackify/backend/services/modlist_gallery_service.py index 0a23dac..9944710 100644 --- a/jackify/backend/services/modlist_gallery_service.py +++ b/jackify/backend/services/modlist_gallery_service.py @@ -91,9 +91,7 @@ class ModlistGalleryService: return metadata except Exception as e: - print(f"Error fetching modlist metadata: {e}") - print("Falling back to cached metadata (may be outdated)") - # Fall back to cache if network/engine fails + logger.warning("Error fetching modlist metadata: %s - falling back to cache", e) return self._load_from_cache() def _fetch_from_engine( @@ -164,7 +162,7 @@ class ModlistGalleryService: data = json.load(f) return parse_modlist_metadata_response(data) except Exception as e: - print(f"Error loading cache: {e}") + logger.warning("Error loading metadata cache: %s", e) return None def _save_to_cache(self, metadata: ModlistMetadataResponse): @@ -182,7 +180,7 @@ class ModlistGalleryService: json.dump(data, f, indent=2) except Exception as e: - print(f"Error saving cache: {e}") + logger.warning("Error saving metadata cache: %s", e) def _metadata_to_dict(self, metadata: ModlistMetadata) -> dict: """Convert ModlistMetadata to dict for JSON serialization""" @@ -306,7 +304,7 @@ class ModlistGalleryService: ) return result.returncode == 0 except Exception as e: - print(f"Error downloading images: {e}") + logger.warning("Error downloading gallery images: %s", e) return False def get_cached_image_path(self, metadata: ModlistMetadata, size: str = "large") -> Optional[Path]: diff --git a/jackify/backend/services/modlist_service.py b/jackify/backend/services/modlist_service.py index ba40cb9..f11a936 100644 --- a/jackify/backend/services/modlist_service.py +++ b/jackify/backend/services/modlist_service.py @@ -103,10 +103,22 @@ class ModlistService(ModlistServiceInstallationMixin): elif game_type_lower == 'enderal': raw_modlists = [m for m in raw_modlists if 'enderal' in m.get('game', '').lower()] - + + elif game_type_lower == 'skyrimvr': + raw_modlists = [m for m in raw_modlists if 'skyrim vr' in m.get('game', '').lower()] + + elif game_type_lower == 'fallout4vr': + raw_modlists = [m for m in raw_modlists if 'fallout 4 vr' in m.get('game', '').lower()] + + elif game_type_lower == 'cp2077': + raw_modlists = [m for m in raw_modlists if 'cyberpunk' in m.get('game', '').lower()] + + elif game_type_lower == 'bg3': + raw_modlists = [m for m in raw_modlists if "baldur" in m.get('game', '').lower()] + elif game_type_lower == 'other': # Exclude all main category games to show only "Other" games - main_category_keywords = ['skyrim', 'fallout 4', 'fallout new vegas', 'oblivion', 'starfield', 'enderal'] + main_category_keywords = ['skyrim', 'fallout 4', 'fallout new vegas', 'oblivion', 'starfield', 'enderal', 'cyberpunk', "baldur's gate", 'skyrim vr', 'fallout 4 vr'] def is_main_category(game_name): game_lower = game_name.lower() return any(keyword in game_lower for keyword in main_category_keywords) diff --git a/jackify/backend/services/modlist_service_installation.py b/jackify/backend/services/modlist_service_installation.py index 6f0905e..653fbee 100644 --- a/jackify/backend/services/modlist_service_installation.py +++ b/jackify/backend/services/modlist_service_installation.py @@ -150,16 +150,17 @@ class ModlistServiceInstallationMixin: elif context.get('machineid'): cmd += ['-m', context['machineid']] cmd += ['-o', install_dir_str, '-d', download_dir_str] - if context.get('skip_disk_check'): - cmd.append('--skip-disk-check') + writeback_path = str(auth_service.get_token_writeback_path()) original_env_values = { 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), '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 @@ -285,6 +286,7 @@ class ModlistServiceInstallationMixin: _ck_missing = True proc.wait() + auth_service.apply_token_writeback(writeback_path) if proc.returncode != 0: if output_callback: output_callback(f"Jackify Install Engine exited with code {proc.returncode}.") diff --git a/jackify/backend/services/nexus_auth_service.py b/jackify/backend/services/nexus_auth_service.py index 23c8fd9..f3dc06c 100644 --- a/jackify/backend/services/nexus_auth_service.py +++ b/jackify/backend/services/nexus_auth_service.py @@ -6,6 +6,7 @@ Unified service for Nexus authentication using OAuth or API key fallback """ import logging +import os from typing import Optional, Tuple from .nexus_oauth_service import NexusOAuthService from ..handlers.oauth_token_handler import OAuthTokenHandler @@ -288,6 +289,41 @@ class NexusAuthService: logger.warning("No authentication available for engine") return (None, None) + def get_token_writeback_path(self) -> 'Path': + """Return a PID-unique path where the engine should write back refreshed tokens.""" + from pathlib import Path + from jackify.shared.paths import get_jackify_data_dir + return get_jackify_data_dir() / f"oauth_writeback_{os.getpid()}.json" + + def apply_token_writeback(self, writeback_path) -> bool: + """ + Read engine-written token writeback file and update local token store. + Called after engine process exits. No-op if file does not exist (engine not yet + supporting writeback, or API key auth was used). + """ + import json + from pathlib import Path + path = Path(writeback_path) + if not path.exists(): + return False + try: + data = json.loads(path.read_text()) + oauth = data.get('oauth', {}) + if oauth.get('access_token') and oauth.get('refresh_token'): + self.token_handler.save_token({'oauth': oauth}) + logger.info("Applied OAuth token writeback from engine - refresh token rotation preserved") + return True + logger.debug("Token writeback file present but contains no usable OAuth data") + return False + except Exception as e: + logger.warning("Failed to apply token writeback: %s", e) + return False + finally: + try: + path.unlink(missing_ok=True) + except Exception: + pass + def clear_all_auth(self) -> bool: """ Clear all authentication (both OAuth and API key) diff --git a/jackify/backend/services/nexus_premium_service.py b/jackify/backend/services/nexus_premium_service.py index 4e548c5..2b171ff 100644 --- a/jackify/backend/services/nexus_premium_service.py +++ b/jackify/backend/services/nexus_premium_service.py @@ -26,7 +26,7 @@ class NexusPremiumService: is_oauth: True when auth_token is an OAuth Bearer token. Returns: - (is_premium, username) — both None/False on failure. + (is_premium, username) - both None/False on failure. """ cached = self._read_cache(auth_token, is_oauth=is_oauth) if cached is not None: diff --git a/jackify/backend/services/steam_restart_service.py b/jackify/backend/services/steam_restart_service.py index cb5e84e..0bcb872 100644 --- a/jackify/backend/services/steam_restart_service.py +++ b/jackify/backend/services/steam_restart_service.py @@ -20,8 +20,6 @@ def _get_restart_strategy() -> str: from jackify.backend.handlers.config_handler import ConfigHandler strategy = ConfigHandler().get("steam_restart_strategy", STRATEGY_JACKIFY) - if strategy == "nak_simple": - strategy = STRATEGY_SIMPLE if strategy not in (STRATEGY_JACKIFY, STRATEGY_SIMPLE): return STRATEGY_JACKIFY return strategy @@ -203,7 +201,7 @@ def is_flatpak_steam() -> bool: def ensure_flatpak_steam_filesystem_access(path: "Path") -> bool: """Grant Flatpak Steam filesystem access to the parent of the given path. - Safe to call on non-Flatpak systems — returns True immediately. + Safe to call on non-Flatpak systems - returns True immediately. Skips if the path is already covered by an existing override. Returns True if access was already present or successfully granted, False on error. """ @@ -212,7 +210,7 @@ def ensure_flatpak_steam_filesystem_access(path: "Path") -> bool: return True flatpak_cmd = _get_flatpak_command() if not flatpak_cmd: - logger.warning("Flatpak Steam detected but flatpak command not found — cannot grant filesystem access") + logger.warning("Flatpak Steam detected but flatpak command not found - cannot grant filesystem access") return False grant_path = str(_Path(path).parent) env = _get_clean_subprocess_env() diff --git a/jackify/backend/services/steamgriddb_service.py b/jackify/backend/services/steamgriddb_service.py new file mode 100644 index 0000000..ce193f5 --- /dev/null +++ b/jackify/backend/services/steamgriddb_service.py @@ -0,0 +1,181 @@ +""" +SteamGridDB artwork fetching service. + +Fetches top-voted artwork for a game from steamgriddb.com using the +official API. Used as a fallback when a modlist has no SteamIcons/ directory. + +PRIVATE: This file contains an obfuscated API key. Do NOT sync to public-src. +""" + +import base64 +import logging +import urllib.request +import urllib.error +import json +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +_BASE_URL = "https://www.steamgriddb.com/api/v2" + +# Obfuscated Jackify service key - XOR with mask, base64-encoded. +# Keep this file out of public-src. +_OBF = b"LgRUXwtXTwUEAw02cnR7EHgEVFldXklTUlFQNiQmJBM=" +_MSK = b"Jackify2024SGDB!Jackify2024SGDB!" + + +def _get_api_key() -> str: + raw = base64.b64decode(_OBF) + return bytes(a ^ b for a, b in zip(raw, _MSK)).decode() + +# Steam App IDs for each Jackify game type key +GAME_STEAM_APP_IDS = { + "skyrim": "489830", + "skyrimvr": "611670", + "fo4": "377160", + "fallout4vr": "611660", + "fnv": "22380", + "fo3": "22300", + "oblivion": "22330", + "oblivion_remastered": "2623190", + "enderal": "976620", + "starfield": "1716740", + "cp2077": "1091500", + "bg3": "1086940", +} + +# Artwork slots: (endpoint_path, query_string, dest_filename) +_ARTWORK_SLOTS = [ + ("grids", "dimensions=600x900&types=static&nsfw=false", "grid-tall.png"), + ("grids", "dimensions=920x430&types=static&nsfw=false", "grid-wide.png"), + ("heroes", "dimensions=1920x620&types=static&nsfw=false", "grid-hero.png"), + ("logos", "types=static&nsfw=false", "grid-logo.png"), +] + + +def _api_get(endpoint: str, api_key: str) -> Optional[dict]: + url = f"{_BASE_URL}/{endpoint}" + req = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {api_key}", + "User-Agent": "Jackify/0.6", + }) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + logger.warning(f"SteamGridDB API error {e.code} for {url}") + except Exception as e: + logger.warning(f"SteamGridDB request failed for {url}: {e}") + return None + + +def _download(url: str, dest: Path) -> bool: + try: + req = urllib.request.Request(url, headers={"User-Agent": "Jackify/0.6"}) + with urllib.request.urlopen(req, timeout=15) as resp: + dest.write_bytes(resp.read()) + return True + except Exception as e: + logger.warning(f"Failed to download {url}: {e}") + return False + + +def detect_game_type_from_modlist(modlist_dir: str) -> Optional[str]: + """Read gameName= from ModOrganizer.ini and return the Jackify game type key. + + Covers all supported game types. Returns None if the ini cannot be read or + the game is not in GAME_STEAM_APP_IDS. + """ + if not modlist_dir: + return None + try: + from pathlib import Path as _Path + mo2_ini = _Path(modlist_dir) / "ModOrganizer.ini" + if not mo2_ini.exists(): + mo2_ini = _Path(modlist_dir) / "files" / "ModOrganizer.ini" + if not mo2_ini.exists(): + return None + content = mo2_ini.read_text(errors='ignore').lower() + game_name_value = "" + for _line in content.splitlines(): + stripped = _line.strip() + if "=" not in stripped: + continue + key, value = stripped.split("=", 1) + if key.strip().lower() == "gamename": + game_name_value = value.strip() + break + gn = game_name_value.strip() + if gn: + if 'skyrim vr' in gn or 'skyrimvr' in gn: + return "skyrimvr" + if 'fallout 4 vr' in gn or 'fallout4vr' in gn: + return "fallout4vr" + if 'skyrim special edition' in gn: + return "skyrim" + if 'fallout new vegas' in gn or 'falloutnv' in gn or 'new vegas' in gn or gn == 'ttw': + return "fnv" + if 'fallout3' in gn or ('fallout 3' in gn and 'fallout 4' not in gn): + return "fo3" + if 'fallout 4' in gn: + return "fo4" + if 'starfield' in gn: + return "starfield" + if 'oblivion remastered' in gn: + return "oblivion_remastered" + if 'oblivion' in gn: + return "oblivion" + if 'enderal' in gn: + return "enderal" + if 'cyberpunk' in gn or 'cp2077' in gn: + return "cp2077" + if "baldur" in gn or 'bg3' in gn: + return "bg3" + else: + # gameName= absent - fall back to content scan for common markers + if 'skyrim special edition' in content or 'skse64_loader' in content: + return "skyrim" + if 'nvse_loader' in content or 'falloutnv' in content: + return "fnv" + if 'fose_loader' in content: + return "fo3" + if 'f4se_loader' in content: + return "fo4" + if 'baldur' in content or 'bg3' in content: + return "bg3" + if 'cyberpunk' in content or 'cp2077' in content: + return "cp2077" + if 'starfield' in content: + return "starfield" + except Exception as e: + logger.debug(f"detect_game_type_from_modlist failed for {modlist_dir}: {e}") + return None + + +def fetch_artwork(game_type: str, dest_dir: Path) -> 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. + """ + steam_appid = GAME_STEAM_APP_IDS.get(game_type) + if not steam_appid: + logger.debug(f"No Steam App ID mapping for game type: {game_type}") + return 0 + + api_key = _get_api_key() + downloaded = 0 + for endpoint, query, filename in _ARTWORK_SLOTS: + 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 + + return downloaded diff --git a/jackify/backend/services/tool_config_service.py b/jackify/backend/services/tool_config_service.py new file mode 100644 index 0000000..b98b5d3 --- /dev/null +++ b/jackify/backend/services/tool_config_service.py @@ -0,0 +1,600 @@ +""" +Tool compatibility configuration service. + +Applies Wine registry settings required for modding tools to work correctly +on Linux. Applied automatically during prefix setup and available as a +standalone operation for existing prefixes. + +Based on research into NaK's registry configuration (external reference only). +""" + +import logging +import os +import subprocess +import tempfile +import urllib.request +from pathlib import Path +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Registry content +# --------------------------------------------------------------------------- + +# xEdit family executables that require WinXP compatibility mode. +# Wine's default Windows version causes xEdit to fail on certain operations. +_XEDIT_EXECUTABLES = [ + "SSEEdit.exe", "SSEEdit64.exe", + "FO4Edit.exe", "FO4Edit64.exe", + "TES4Edit.exe", "TES4Edit64.exe", + "xEdit64.exe", + "SF1Edit64.exe", + "FNVEdit.exe", "FNVEdit64.exe", + "xFOEdit.exe", "xFOEdit64.exe", + "xSFEEdit.exe", "xSFEEdit64.exe", + "xTESEdit.exe", "xTESEdit64.exe", + "FO3Edit.exe", "FO3Edit64.exe", +] + +# DLL overrides applied to the prefix globally. +# All set to native,builtin so game/tool-provided DLLs take priority. +_DLL_OVERRIDES = [ + "dwrite", + "winmm", + "version", + "dxgi", + "dbghelp", + "d3d12", + "wininet", + "winhttp", + "dinput", + "dinput8", +] + + +def _build_reg_content() -> str: + lines = ["Windows Registry Editor Version 5.00", ""] + + # xEdit WinXP compatibility + for exe in _XEDIT_EXECUTABLES: + lines.append(f"[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\{exe}]") + lines.append('"Version"="winxp"') + lines.append("") + + # Pandora Behaviour Engine - decorated window causes UI glitches on Linux + lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\Pandora Behaviour Engine+.exe\\X11 Driver]") + lines.append('"Decorated"="N"') + lines.append("") + + # Skyrim SE / SKSE game process needs native mscoree to load dotnet4 correctly. + # Scoped to SkyrimSE.exe only so it does not interfere with .NET 9/10 tools + # (Synthesis, SDK host) that run in the same prefix. + lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides]") + lines.append('"*mscoree"="native"') + lines.append("") + + # Prevent Wine windows from stealing keyboard focus via WM_TAKE_FOCUS. + # Without this, each Wine subprocess launched during winetricks installs + # briefly grabs X11 focus (via XWayland), interrupting whatever the user + # is typing in other applications. + lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\X11 Driver]") + lines.append('"UseTakeFocus"="N"') + lines.append("") + + # Global DLL overrides + lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides]") + for dll in _DLL_OVERRIDES: + lines.append(f'"{dll}"="native,builtin"') + lines.append("") + + 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. +# 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" + +# .NET Desktop Runtime 10 - provides NETCore.App + WindowsDesktop.App 10.0.2. +# Covers Synthesis patchers targeting .NET 10 runtime. +_DOTNET10_DESKTOP_URL = "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.2/windowsdesktop-runtime-10.0.2-win-x64.exe" +_DOTNET10_DESKTOP_FILENAME = "windowsdesktop-runtime-10.0.2-win-x64.exe" + +# DigiCert Universal Root CA - required for NuGet package signature validation. +# Without this, dotnet fails to verify NuGet package signatures when Synthesis +# compiles patchers. Imported into the Wine prefix Windows cert store so no +# system-level changes are needed. +_DIGICERT_CERT_URL = "https://cacerts.digicert.com/DigiCertTrustedRootG4.crt.pem" +_DIGICERT_CERT_FILENAME = "DigiCertTrustedRootG4.crt.pem" + +# fxc2 build of d3dcompiler_47 - required for Community Shaders shader compilation. +# The winetricks-provided d3dcompiler_47 lacks support for certain shader models +# used by Community Shaders, causing "failed shaders" during compilation. +_FXC2_D3DCOMPILER_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompiler_47.dll" +_FXC2_D3DCOMPILER_FILENAME = "fxc2_d3dcompiler_47.dll" + + +def _install_dotnet9_sdk( + prefix_path: Path, + wine_bin: str, + log: Callable[[str], None], +) -> bool: + """ + Download and install the .NET 9 SDK into the Wine prefix. + Cached to avoid re-downloading on subsequent runs. + """ + 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 + + if not installer.exists(): + log(f"Downloading .NET 9 SDK ({_DOTNET9_SDK_FILENAME})...") + urllib.request.urlretrieve(_DOTNET9_SDK_URL, installer) + log(".NET 9 SDK downloaded") + else: + log(".NET 9 SDK installer 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") + return True + + except Exception as e: + log(f"Failed to install .NET 9 SDK: {e}") + return False + + + +def _install_dotnet10_desktop_runtime( + prefix_path: Path, + wine_bin: str, + log: Callable[[str], None], +) -> bool: + """ + Download and install the .NET Desktop Runtime 10 into the Wine prefix. + Provides NETCore.App and WindowsDesktop.App 10.x for patchers targeting .NET 10. + """ + 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 / _DOTNET10_DESKTOP_FILENAME + + if not installer.exists(): + log(f"Downloading .NET Desktop Runtime 10 ({_DOTNET10_DESKTOP_FILENAME})...") + urllib.request.urlretrieve(_DOTNET10_DESKTOP_URL, installer) + log(".NET Desktop Runtime 10 downloaded") + else: + log(".NET Desktop Runtime 10 already cached, skipping download") + + log("Installing .NET Desktop Runtime 10...") + 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=300, + ) + + if result.returncode not in (0, 3010): + log(f".NET Desktop Runtime 10 installer exited with code {result.returncode}") + return False + + log(".NET Desktop Runtime 10 installed successfully") + return True + + except Exception as e: + log(f"Failed to install .NET Desktop Runtime 10: {e}") + return False + + +def _install_nuget_cert( + prefix_path: Path, + wine_bin: str, + log: Callable[[str], None], +) -> bool: + """ + Import the DigiCert Trusted Root G4 CA into the Wine prefix Windows cert + store. Required for NuGet package signature validation when Synthesis + compiles patchers. Uses wine certutil so no system-level changes are needed. + """ + 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) + cert_file = cache_dir / _DIGICERT_CERT_FILENAME + + if not cert_file.exists(): + log(f"Downloading DigiCert Trusted Root G4 certificate...") + urllib.request.urlretrieve(_DIGICERT_CERT_URL, cert_file) + log("Certificate downloaded") + else: + log("DigiCert certificate already cached, skipping download") + + log("Importing certificate into Wine prefix cert store...") + env = os.environ.copy() + env["WINEPREFIX"] = str(prefix_path) + env["WINEDEBUG"] = "-all" + env["WINEDLLOVERRIDES"] = "winemenubuilder.exe=d" + env["DISPLAY"] = env.get("DISPLAY", ":0") + + result = subprocess.run( + [wine_bin, "certutil", "-addstore", "Root", str(cert_file)], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode != 0: + log(f"certutil exited with code {result.returncode} (may already be installed)") + else: + log("DigiCert certificate imported into Wine cert store") + return True + + except Exception as e: + log(f"Failed to install NuGet certificate: {e}") + return False + + + +def _install_fxc2_d3dcompiler( + prefix_path: Path, + log: Callable[[str], None], +) -> bool: + """ + Replace the winetricks-installed d3dcompiler_47.dll with the Mozilla fxc2 + build, which supports shader models required by Community Shaders. + Applies to both system32 (64-bit) and syswow64 (32-bit) locations. + """ + 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) + cached_dll = cache_dir / _FXC2_D3DCOMPILER_FILENAME + + if not cached_dll.exists(): + log("Downloading fxc2 d3dcompiler_47.dll...") + urllib.request.urlretrieve(_FXC2_D3DCOMPILER_URL, cached_dll) + log("fxc2 d3dcompiler_47.dll downloaded") + else: + log("fxc2 d3dcompiler_47.dll already cached, skipping download") + + import shutil + targets = [ + prefix_path / "drive_c" / "windows" / "system32" / "d3dcompiler_47.dll", + prefix_path / "drive_c" / "windows" / "syswow64" / "d3dcompiler_47.dll", + ] + for target in targets: + if target.parent.exists(): + shutil.copy2(cached_dll, target) + log(f"Installed fxc2 d3dcompiler_47.dll -> {target.parent.name}") + + return True + + except Exception as e: + log(f"Failed to install fxc2 d3dcompiler_47.dll (non-fatal): {e}") + return False + + +def _set_windows_version_win11( + prefix_path: Path, + wine_bin: str, + log: Callable[[str], None], +) -> None: + """ + Set the Wine prefix Windows version to Windows 11. + Matches Fluorine's prefix configuration; required for .NET 9/10 to run + correctly. winetricks components may leave the prefix at a lower version. + """ + try: + from pathlib import Path as _Path + module_dir = _Path(__file__).parent.parent.parent + winetricks_bin = str(module_dir / "tools" / "winetricks") + if not os.path.exists(winetricks_bin): + appdir = os.environ.get("APPDIR", "") + if appdir: + winetricks_bin = os.path.join(appdir, "opt", "jackify", "tools", "winetricks") + if not os.path.exists(winetricks_bin): + log("Bundled winetricks not found - skipping Windows version update") + return + + log("Setting Windows version to Windows 11...") + env = os.environ.copy() + env["WINEPREFIX"] = str(prefix_path) + env["WINE"] = wine_bin + env["WINEDEBUG"] = "-all" + env["DISPLAY"] = env.get("DISPLAY", ":0") + + result = subprocess.run( + [winetricks_bin, "-q", "win11"], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode != 0: + log(f"winetricks win11 exited with code {result.returncode} (non-fatal)") + else: + log("Windows version set to Windows 11") + + except subprocess.TimeoutExpired: + log("winetricks win10 timed out (non-fatal)") + except Exception as e: + log(f"Failed to set Windows version: {e} (non-fatal)") + + +# --------------------------------------------------------------------------- +# Application +# --------------------------------------------------------------------------- + +def apply_tool_config( + compatdata_path: str, + wine_bin: str, + log: Optional[Callable[[str], None]] = None, + install_dotnet9_sdk: bool = False, + install_fxc2_d3dcompiler: bool = False, +) -> bool: + """ + Apply tool compatibility settings to the Wine prefix. + + install_dotnet9_sdk=True downloads and installs the .NET 9/10 SDK, which is + required for Synthesis. Intentionally opt-in - the download is ~220MB and + only appropriate when the user explicitly runs Configure Tool Compatibility + from Additional Tasks. + + install_fxc2_d3dcompiler=True replaces d3dcompiler_47.dll with the Mozilla + fxc2 build. Only appropriate for Skyrim SE/AE modlists using Community Shaders. + + Returns True if registry settings applied successfully (dotnet SDK install + failures are non-fatal since the registry settings still have value). + """ + def _log(msg: str): + logger.info(msg) + if log: + log(msg) + + prefix_path = Path(compatdata_path) / "pfx" + if not prefix_path.exists(): + _log(f"Wine prefix not found at {prefix_path}") + return False + + if install_fxc2_d3dcompiler: + _install_fxc2_d3dcompiler(prefix_path, _log) + + if install_dotnet9_sdk: + _install_dotnet9_sdk(prefix_path, wine_bin, _log) + _install_dotnet10_desktop_runtime(prefix_path, wine_bin, _log) + _install_nuget_cert(prefix_path, wine_bin, _log) + _set_windows_version_win11(prefix_path, wine_bin, _log) + + # 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}") + + reg_content = _build_reg_content() + + try: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".reg", delete=False, encoding="utf-8" + ) as tf: + tf.write(reg_content) + reg_file = tf.name + + _log("Applying tool compatibility registry settings...") + env = os.environ.copy() + env["WINEPREFIX"] = str(prefix_path) + env["WINEDEBUG"] = "-all" + env["DISPLAY"] = env.get("DISPLAY", ":0") + + result = subprocess.run( + [wine_bin, "regedit", reg_file], + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode != 0: + _log(f"wine regedit exited with code {result.returncode}: {result.stderr[:200]}") + return False + + _log(f"Tool compatibility settings applied ({len(_XEDIT_EXECUTABLES)} xEdit variants, Pandora, {len(_DLL_OVERRIDES)} DLL overrides)") + return True + + except subprocess.TimeoutExpired: + _log("wine regedit timed out after 30 seconds") + return False + except Exception as e: + _log(f"Failed to apply tool config: {e}") + return False + finally: + try: + os.unlink(reg_file) + except Exception: + pass + + +def setup_nemesis_compatibility( + modlist_dir: str, + stock_game_path: Optional[str], + log: Optional[Callable[[str], None]] = None, +) -> None: + """ + Prepare Nemesis Unlimited Behavior Engine to run correctly on Linux. + + Two issues affect Nemesis under Wine/MO2 on Linux: + 1. Nemesis resolves a relative `mods` path against the filesystem root, + causing a "cannot access /mods" error. Symlinking Nemesis_Engine from + the mod directory into the real Data directory fixes this. + 2. A non-blank "Start In" (workingDirectory) in ModOrganizer.ini causes + Nemesis to hang. Blank it out for the Nemesis executable entry. + + Non-fatal - logs failures but does not raise. + """ + def _log(msg: str): + logger.info(msg) + if log: + log(msg) + + modlist_path = Path(modlist_dir) + mods_dir = modlist_path / "mods" + + if not mods_dir.is_dir(): + _log("Nemesis setup: mods directory not found, skipping") + return + + # Find the Nemesis_Engine directory inside the mods tree + nemesis_engine_src: Optional[Path] = None + try: + for mod_dir in mods_dir.iterdir(): + candidate = mod_dir / "Nemesis_Engine" + if candidate.is_dir(): + nemesis_engine_src = candidate + break + except Exception as e: + _log(f"Nemesis setup: error scanning mods directory: {e}") + return + + if nemesis_engine_src is None: + _log("Nemesis setup: Nemesis_Engine not found in mods - modlist may not include Nemesis") + return + + # Create symlink in Data/ so Nemesis can find its engine at a predictable path + if stock_game_path: + data_dir = Path(stock_game_path) / "Data" + try: + data_dir.mkdir(parents=True, exist_ok=True) + symlink_path = data_dir / "Nemesis_Engine" + if symlink_path.is_symlink(): + existing_target = symlink_path.resolve() + if existing_target == nemesis_engine_src.resolve(): + _log("Nemesis setup: symlink already correct, skipping") + else: + symlink_path.unlink() + symlink_path.symlink_to(nemesis_engine_src) + _log(f"Nemesis setup: updated symlink at {symlink_path}") + elif symlink_path.exists(): + _log(f"Nemesis setup: {symlink_path} exists and is not a symlink - leaving it alone") + else: + symlink_path.symlink_to(nemesis_engine_src) + _log(f"Nemesis setup: created symlink {symlink_path} -> {nemesis_engine_src}") + except Exception as e: + _log(f"Nemesis setup: failed to create symlink: {e}") + else: + _log("Nemesis setup: no stock game path available - skipping symlink") + + # Blank workingDirectory for the Nemesis executable in ModOrganizer.ini + mo2_ini = modlist_path / "ModOrganizer.ini" + if not mo2_ini.is_file(): + _log("Nemesis setup: ModOrganizer.ini not found, skipping workingDirectory fix") + return + + try: + content = mo2_ini.read_text(encoding="utf-8") + except Exception as e: + _log(f"Nemesis setup: could not read ModOrganizer.ini: {e}") + return + + import re + + # Find all executable indices whose binary points to Nemesis + nemesis_indices = re.findall( + r'^(\d+)\\binary=.*Nemesis Unlimited Behavior Engine\.exe', + content, + re.MULTILINE | re.IGNORECASE, + ) + + if not nemesis_indices: + _log("Nemesis setup: no Nemesis executable entry found in ModOrganizer.ini") + return + + modified = content + changed = 0 + for idx in nemesis_indices: + # Replace non-blank workingDirectory for this index + pattern = rf'^({re.escape(idx)}\\workingDirectory=).+$' + replacement = rf'\g<1>' + new_content, n = re.subn(pattern, replacement, modified, flags=re.MULTILINE) + if n: + modified = new_content + changed += n + + if changed: + try: + mo2_ini.write_text(modified, encoding="utf-8") + _log(f"Nemesis setup: blanked workingDirectory for {len(nemesis_indices)} Nemesis executable entry(s) in ModOrganizer.ini") + except Exception as e: + _log(f"Nemesis setup: failed to write ModOrganizer.ini: {e}") + else: + _log("Nemesis setup: workingDirectory already blank for all Nemesis entries") + + +def apply_tool_config_for_appid( + appid: str, + log: Optional[Callable[[str], None]] = None, + install_dotnet9_sdk: bool = True, +) -> bool: + """ + Resolve compatdata path and wine binary from an AppID, then apply tool config. + Convenience wrapper for the standalone Additional Tasks flow. + """ + def _log(msg: str): + logger.info(msg) + if log: + log(msg) + + try: + from jackify.backend.handlers.wine_utils_proton import WineUtilsProtonMixin + compatdata_path, _, wine_bin = WineUtilsProtonMixin.get_proton_paths(appid) + except Exception as e: + _log(f"Could not resolve Proton paths for AppID {appid}: {e}") + return False + + if not compatdata_path or not wine_bin: + _log(f"Could not resolve Wine prefix for AppID {appid}. Is this modlist configured in Steam?") + return False + + return apply_tool_config(compatdata_path, wine_bin, log, install_dotnet9_sdk=install_dotnet9_sdk, install_fxc2_d3dcompiler=True) diff --git a/jackify/backend/services/tool_registry.py b/jackify/backend/services/tool_registry.py new file mode 100644 index 0000000..18dc9b5 --- /dev/null +++ b/jackify/backend/services/tool_registry.py @@ -0,0 +1,503 @@ +""" +Third-party tool registry. + +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. +""" + +import json +import logging +import os +import re +import tarfile +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import requests + +from jackify.shared.paths import get_jackify_data_dir + +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 + display_name: str + description: str + github_repo: str # e.g. "SulfurNitride/CLF3" + asset_patterns: List[str] # ordered list of regex patterns to match release asset filename + tier: int # 1 = Jackify invokes it, 2 = user runs it themselves + 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 + + +@dataclass +class ToolStatus: + definition: ToolDefinition + installed: bool + installed_version: Optional[str] + previous_version: Optional[str] + binary_path: Optional[Path] + latest_version: Optional[str] = None + update_available: bool = False + + @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() + + +# --------------------------------------------------------------------------- +# Tool catalogue +# --------------------------------------------------------------------------- + +TOOL_DEFINITIONS: List[ToolDefinition] = [ + ToolDefinition( + 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=[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, + ), + ToolDefinition( + tool_id="radium", + display_name="Radium Textures", + description="Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation.", + 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, + ), +] + +_TOOL_MAP: Dict[str, ToolDefinition] = {t.tool_id: t for t in TOOL_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(): + try: + return json.loads(mp.read_text()) + except Exception: + pass + return {} + + +def _write_manifest(tool_id: str, data: dict) -> None: + mp = _manifest_path(tool_id) + mp.parent.mkdir(parents=True, exist_ok=True) + 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 + return False, None, None + except Exception as e: + logger.debug("TTW config read 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: + tags = [pinned_version, f"v{pinned_version}"] if not pinned_version.startswith("v") else [pinned_version] + for tag in tags: + url = GITHUB_API.format(repo=github_repo, ref=f"tags/{tag}") + try: + resp = requests.get(url, timeout=10, verify=True) + if resp.status_code == 200: + return resp.json() + except Exception as e: + logger.debug("GitHub fetch error for %s@%s: %s", github_repo, tag, e) + return None + url = GITHUB_API.format(repo=github_repo, ref="latest") + try: + resp = requests.get(url, timeout=10, verify=True) + resp.raise_for_status() + return resp.json() + except Exception as e: + logger.debug("GitHub fetch error for %s: %s", github_repo, e) + return None + + +def _find_asset(release_data: dict, asset_patterns: List[str]) -> Optional[dict]: + assets = release_data.get("assets", []) + for pattern in asset_patterns: + for asset in assets: + if re.search(pattern, asset.get("name", ""), re.IGNORECASE): + return asset + return None + + +# --------------------------------------------------------------------------- +# Core install logic (shared across all non-TTW tools) +# --------------------------------------------------------------------------- + +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.""" + 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: + try: + temp_path.unlink(missing_ok=True) + except Exception: + pass + + return True, "" + + +def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Path]: + for exe_name in tool_def.executable_names: + direct = search_dir / exe_name + if direct.is_file(): + return direct + 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.""" + + def get_status(self, tool_id: str) -> Optional[ToolStatus]: + defn = _TOOL_MAP.get(tool_id) + if defn is None: + return None + return self._build_status(defn) + + def get_all_statuses(self) -> List[ToolStatus]: + return [self._build_status(d) for d in TOOL_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 data: + return data.get("tag_name") or data.get("name") + return None + + def install(self, tool_id: str) -> Tuple[bool, str]: + defn = _TOOL_MAP.get(tool_id) + if defn is None: + return False, f"Unknown tool: {tool_id}" + + 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}" + + 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 + + exe_path = _find_executable(defn, install_dir) + if exe_path: + try: + os.chmod(exe_path, 0o755) + except Exception: + pass + + manifest = _read_manifest(tool_id) + _write_manifest(tool_id, { + "installed_version": tag, + "previous_version": manifest.get("installed_version"), + "binary_path": str(exe_path) if exe_path else None, + "install_dir": str(install_dir), + }) + + logger.info("Installed %s %s", defn.display_name, tag) + 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}" + + if tool_id == "ttw_installer": + return self._install_ttw() + + manifest = _read_manifest(tool_id) + current_dir = TOOLS_BASE_DIR / tool_id + prev_dir = TOOLS_BASE_DIR / tool_id / "_previous" + + # Back up current install before overwriting + if current_dir.exists() and manifest.get("installed_version"): + import shutil + try: + if prev_dir.exists(): + shutil.rmtree(prev_dir) + # Copy current files (excluding _previous subdir) to _previous + prev_dir.mkdir(parents=True, exist_ok=True) + for item in current_dir.iterdir(): + if item.name == "_previous": + continue + dest = prev_dir / item.name + if item.is_file(): + shutil.copy2(item, dest) + elif item.is_dir(): + shutil.copytree(item, dest) + except Exception as e: + logger.warning("Could not back up previous version of %s: %s", tool_id, e) + + ok, msg = self.install(tool_id) + if ok and manifest.get("installed_version"): + # Preserve previous_version in manifest (install() sets it from current manifest) + updated_manifest = _read_manifest(tool_id) + updated_manifest["previous_version"] = manifest.get("installed_version") + _write_manifest(tool_id, updated_manifest) + return ok, msg + + def downgrade(self, tool_id: str) -> Tuple[bool, str]: + """Swap current install with the backed-up previous version.""" + defn = _TOOL_MAP.get(tool_id) + if defn is None: + return False, f"Unknown tool: {tool_id}" + if tool_id == "ttw_installer": + return False, "Downgrade not supported for TTW Linux Installer via this interface" + + import shutil + current_dir = TOOLS_BASE_DIR / tool_id + prev_dir = TOOLS_BASE_DIR / tool_id / "_previous" + + if not prev_dir.exists(): + return False, f"No previous version stored for {defn.display_name}" + + manifest = _read_manifest(tool_id) + current_version = manifest.get("installed_version") + previous_version = manifest.get("previous_version") + + # Swap: move current out, move previous in + swap_dir = TOOLS_BASE_DIR / tool_id / "_swap" + try: + if swap_dir.exists(): + shutil.rmtree(swap_dir) + swap_dir.mkdir(parents=True) + for item in current_dir.iterdir(): + if item.name in ("_previous", "_swap"): + continue + shutil.move(str(item), str(swap_dir / item.name)) + for item in prev_dir.iterdir(): + shutil.move(str(item), str(current_dir / item.name)) + # Put what was current into _previous + if prev_dir.exists(): + shutil.rmtree(prev_dir) + prev_dir.mkdir() + for item in swap_dir.iterdir(): + shutil.move(str(item), str(prev_dir / item.name)) + shutil.rmtree(swap_dir, ignore_errors=True) + except Exception as e: + return False, f"Downgrade failed: {e}" + + exe_path = _find_executable(defn, current_dir) + if exe_path: + try: + os.chmod(exe_path, 0o755) + except Exception: + pass + + _write_manifest(tool_id, { + "installed_version": previous_version, + "previous_version": current_version, + "binary_path": str(exe_path) if exe_path else None, + "install_dir": str(current_dir), + }) + logger.info("Downgraded %s from %s to %s", defn.display_name, current_version, previous_version) + return True, f"{defn.display_name} downgraded to {previous_version}" + + def uninstall(self, tool_id: str) -> Tuple[bool, str]: + defn = _TOOL_MAP.get(tool_id) + if defn is None: + return False, f"Unknown tool: {tool_id}" + if not defn.can_uninstall: + return False, f"{defn.display_name} cannot be uninstalled - Jackify depends on it" + + import shutil + tool_dir = TOOLS_BASE_DIR / tool_id + if tool_dir.exists(): + try: + shutil.rmtree(tool_dir) + except Exception as e: + return False, f"Uninstall failed: {e}" + + logger.info("Uninstalled %s", defn.display_name) + 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 + manifest = _read_manifest(tool_id) + bp = manifest.get("binary_path") + if bp: + p = Path(bp) + if p.is_file(): + return p + return None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _build_status(self, defn: ToolDefinition) -> ToolStatus: + if defn.tool_id == "ttw_installer": + installed, version, binary = _ttw_status_from_config() + return ToolStatus( + definition=defn, + installed=installed, + installed_version=version, + previous_version=None, + binary_path=binary, + ) + manifest = _read_manifest(defn.tool_id) + installed_version = manifest.get("installed_version") + 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()) + return ToolStatus( + definition=defn, + installed=installed, + installed_version=installed_version, + previous_version=manifest.get("previous_version"), + binary_path=binary_path, + ) + + def _install_ttw(self) -> Tuple[bool, str]: + """Delegate TTW install to the existing handler.""" + 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 + fs = FileSystemHandler() + cfg = ConfigHandler() + handler = TTWInstallerHandler( + steamdeck=False, verbose=False, + filesystem_handler=fs, config_handler=cfg, + ) + return handler.install_ttw_installer() + except Exception as e: + return False, f"TTW install failed: {e}" diff --git a/jackify/backend/services/update_service.py b/jackify/backend/services/update_service.py index e3b2748..9899270 100644 --- a/jackify/backend/services/update_service.py +++ b/jackify/backend/services/update_service.py @@ -7,7 +7,9 @@ and coordinating the update process. import logging import os +import shutil import subprocess +import tempfile import threading from dataclasses import dataclass from pathlib import Path @@ -32,6 +34,7 @@ class UpdateInfo: file_size: Optional[int] = None is_critical: bool = False is_delta_update: bool = False + github_download_url: Optional[str] = None class UpdateService: @@ -98,7 +101,7 @@ class UpdateService: break if download_url: - # Prefer Nexus CDN for Premium users if this version is available there + github_url = download_url nexus_url = self._try_nexus_download_url(latest_version) update_source = "github" if nexus_url: @@ -108,16 +111,13 @@ class UpdateService: else: logger.info("Update source: GitHub Releases (version %s)", latest_version) - # Determine if this is a delta update is_delta = '.delta' in download_url or 'delta' in download_url.lower() - # Safety checks to prevent segfault try: - # Sanitize string fields safe_version = str(latest_version) if latest_version else "" safe_tag = str(release_data.get('tag_name', '')) safe_date = str(release_data.get('published_at', '')) - safe_changelog = str(release_data.get('body', ''))[:1000] # Limit size + safe_changelog = str(release_data.get('body', ''))[:1000] safe_url = str(download_url) logger.debug(f"Creating UpdateInfo for version {safe_version}") @@ -131,6 +131,7 @@ class UpdateService: file_size=file_size, is_delta_update=is_delta, source=update_source, + github_download_url=str(github_url), ) logger.debug(f"UpdateInfo created successfully") @@ -159,6 +160,13 @@ class UpdateService: and return a CDN download URL for the file matching target_version. Returns None on any failure or if the version is not yet on Nexus. """ + try: + from jackify.backend.handlers.config_handler import ConfigHandler + if ConfigHandler().get('force_github_updates', False): + logger.info("Nexus update source bypassed: force_github_updates is enabled") + return None + except Exception: + pass try: from jackify.backend.services.nexus_auth_service import NexusAuthService auth_service = NexusAuthService() @@ -301,33 +309,38 @@ class UpdateService: logger.debug(f"Self-updating enabled for AppImage: {appimage_path}") return True - def download_update(self, update_info: UpdateInfo, + def download_update(self, update_info: UpdateInfo, progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]: """ - Download update using full AppImage replacement. - - Since we can't rely on external tools being available, we use a reliable - full replacement approach that works on all systems without dependencies. - - Args: - update_info: Information about the update to download - progress_callback: Optional callback for download progress (bytes_downloaded, total_bytes) - - Returns: - Path to downloaded file, or None if download failed + Download update AppImage. Falls back to GitHub if the primary source fails. """ - try: - logger.info("Downloading update %s from %s (full replacement)", update_info.version, update_info.source) - result = self._download_update_manual(update_info, progress_callback) - if result: - logger.info("Update download complete: %s from %s -> %s", update_info.version, update_info.source, result) - else: - logger.error("Update download failed: %s from %s", update_info.version, update_info.source) + logger.info("Downloading update %s from %s (full replacement)", update_info.version, update_info.source) + result = self._download_update_manual(update_info, progress_callback) + if result: + logger.info("Update download complete: %s from %s -> %s", update_info.version, update_info.source, result) return result - - except Exception as e: - logger.error(f"Failed to download update: {e}") - return None + + # Primary source failed - fall back to GitHub if we came from Nexus + if update_info.source == "nexus" and update_info.github_download_url: + logger.warning("Nexus download failed, falling back to GitHub") + fallback = UpdateInfo( + version=update_info.version, + tag_name=update_info.tag_name, + release_date=update_info.release_date, + changelog=update_info.changelog, + download_url=update_info.github_download_url, + source="github", + file_size=update_info.file_size, + is_delta_update=False, + github_download_url=update_info.github_download_url, + ) + result = self._download_update_manual(fallback, progress_callback) + if result: + logger.info("Update download complete via GitHub fallback: %s -> %s", update_info.version, result) + return result + + logger.error("Update download failed: %s", update_info.version) + return None def _download_update_manual(self, update_info: UpdateInfo, progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]: @@ -414,27 +427,41 @@ class UpdateService: return None def _extract_appimage_from_7z(self, archive: Path, dest_dir: Path, version: str) -> Optional[Path]: - """Extract Jackify.AppImage from a 7z archive into dest_dir.""" + """Extract AppImage from a 7z archive into dest_dir.""" seven_z = self._get_bundled_7z_path() if not seven_z: logger.error("Bundled 7z not found, cannot extract update archive") return None out_path = dest_dir / f"Jackify-{version}.AppImage" + if out_path.exists(): + out_path.unlink() + tmp_dir = Path(tempfile.mkdtemp(dir=dest_dir)) try: result = subprocess.run( - [str(seven_z), 'e', str(archive), 'Jackify.AppImage', f'-o{dest_dir}', '-y'], + [str(seven_z), 'e', str(archive), f'-o{tmp_dir}', '-y'], capture_output=True, text=True, timeout=120 ) - extracted = dest_dir / 'Jackify.AppImage' - if result.returncode != 0 or not extracted.exists(): + if result.returncode != 0: logger.error("7z extraction failed (rc=%d): %s", result.returncode, result.stderr.strip()) return None - extracted.rename(out_path) - logger.info("Extracted AppImage from archive: %s", out_path) + candidates = list(tmp_dir.glob('*.AppImage')) + if not candidates: + logger.error("No .AppImage found in archive contents: %s", + [p.name for p in tmp_dir.iterdir()]) + return None + extracted = candidates[0] + logger.debug("Found %s in archive (%d bytes)", extracted.name, extracted.stat().st_size) + shutil.move(str(extracted), str(out_path)) + if not out_path.exists(): + logger.error("AppImage missing after move to %s", out_path) + return None + logger.info("Extracted AppImage to %s (%d bytes)", out_path, out_path.stat().st_size) return out_path except Exception as e: logger.error("Exception during 7z extraction: %s", e) return None + finally: + shutil.rmtree(str(tmp_dir), ignore_errors=True) def apply_update(self, new_appimage_path: Path) -> bool: """ diff --git a/jackify/backend/utils/cc_content_detector.py b/jackify/backend/utils/cc_content_detector.py index f51ff50..42f5cdf 100644 --- a/jackify/backend/utils/cc_content_detector.py +++ b/jackify/backend/utils/cc_content_detector.py @@ -6,7 +6,7 @@ import re from typing import Optional # Matches CC content file names: ccXXXsse001-name.bsa/esm/esl/esp, ccXXXfo4001-name.ba2, etc. -# No leading \b — filenames often appear with a Data_ prefix (Data_ccbgssse019-...) +# No leading \b - filenames often appear with a Data_ prefix (Data_ccbgssse019-...) # where _ is a word char and would prevent \b from matching. _CC_FILE_RE = re.compile( r'cc[a-z]{2,8}\d{3,4}[-\w]*\.(?:bsa|esm|esl|esp|ba2)', diff --git a/jackify/backend/utils/engine_error_parser.py b/jackify/backend/utils/engine_error_parser.py index ab1613b..67e1b72 100644 --- a/jackify/backend/utils/engine_error_parser.py +++ b/jackify/backend/utils/engine_error_parser.py @@ -53,7 +53,7 @@ _TYPE_MAP = { suggestion="Check your internet connection and retry.", solutions=[ "Verify your internet connection.", - "Re-run the install — Wabbajack resumes from where it stopped.", + "Re-run the install - Wabbajack resumes from where it stopped.", "Check if Nexus Mods is reachable at nexusmods.com.", "Disable VPN or proxy if active.", ], @@ -84,7 +84,7 @@ _TYPE_MAP = { "archive_corrupt": lambda msg, ctx: InstallError( "Corrupted Archive", msg, - suggestion="Re-run the install — Wabbajack will re-download and re-verify the file.", + suggestion="Re-run the install - Wabbajack will re-download and re-verify the file.", solutions=[ "Re-run the install.", "Check available disk space (partial downloads appear corrupt).", @@ -99,7 +99,7 @@ _TYPE_MAP = { solutions=[ "Verify the modlist name is correct.", "Ensure the target game is installed.", - "Re-run — the modlist index may have been temporarily unavailable.", + "Re-run - the modlist index may have been temporarily unavailable.", ], technical=_ctx_detail(ctx), ), @@ -108,7 +108,7 @@ _TYPE_MAP = { msg, suggestion="Re-run the install to re-download any failed files.", solutions=[ - "Re-run the install — Wabbajack resumes and re-validates.", + "Re-run the install - Wabbajack resumes and re-validates.", "Check available disk space.", "Check Modlist_Install_workflow.log for specific failures.", ], diff --git a/jackify/engine/Wabbajack.CLI.Builder.dll b/jackify/engine/Wabbajack.CLI.Builder.dll index 2a92a4fca060110ccfe949b8cd2d883f28d95cfd..caed69e6d560e612fe9f714af7d14e15985dfd5b 100644 GIT binary patch delta 307 zcmZoT!PszuaY6^n66gG>8+(4KGqOx>*XU$q*(|7;uOYB+>T)le5L@TzR#C#67bgd7 zo)8$zVQZpiz`$V4$e?GSXR2qSZDMMaoNAVwXl!6+Xq1*@Xl9v|Y+-7gnrduhVqubI zW@2b*l9X&<#=v0C$Y3=2V5I0|`>2&H|HPNBp8O^%K*0V(jJZ`!k1CNdZ^7ywy@K(RE2Bp_|ZUomjw`~ b0cA~qYAk^yNDWZu}KzIU|G7(iqGbl7M_u216j71Qa&{(`i6?5FbPvgVm=3*#--HOVF%OjXhg?7~`HXvP^&dj8PRx2|i~O7G+_8f%pXw7AphGbj#Od8V&l%qXslXQ?-uB8DjQcMN%x?McQRaG`&+VGdWqP^Qh}rqP?rS|rvYV6fNCs( gBuEWV=k$7cW>Z!Z20aFY=@X@yJ+?C{F#l%+0FnA>6aWAK delta 380 zcmZoT!qaesXF>Od8V&l%qXslXQ?-uB8DjQcMNJo=@z#BIN|=YlwY;}*x-H`_gSF-bVs8tWM_Fc>p3 z=o#pl>KSXNCR-+&o2Hp1C7T*1C0QCJC8Z^TuyL|wYMOzGNuo(&YI2IP1p|XQBZJZO zgx^e>+mHQX3KL-2u<^mt=?dJ;0RjvBRr=fC#=GpU(%aec+iUam25#mw1*qtg-%wGY zde!=!iHAZfH#hw%ow;K>8xM1b04Fm8E7&{C+o#JghcYr6Z+|PxY|j{M%%IJX%8<-p z$&kok&S1)r#$d*f1mv4C7y{`eptup3P6Nt=_#oOCtUeXUHefIT!bC6znUVsOvzT5l Y&uq$S%%I0$Fnyvlv&VKu1?K;Z0JmUpWdHyG diff --git a/jackify/engine/Wabbajack.Compiler.dll b/jackify/engine/Wabbajack.Compiler.dll index cda353dcaf2c0057e4a42110106035888f0fd231..036e4170da264e67ef967c4b4a00fa8a4353f746 100644 GIT binary patch delta 500 zcmZp8!rAbIb3zBpVZ~D$8hf_(Fh+GTvP^&8#i%OE!TU3G51S7Dl8|Nr{-C)`ZmJ#9?%3>X-U85#5p^i1_kv`tKn zl2gr+6O9eb42{x~49zT)k}XV)Q&WwNOe{>&%uEa|O_Gug%orG07#XHFhA|m$_X=Z* z5@wmSZ$`)T6`4!{0%oyKI3@l+@!b5D!S&s}?&j$nSxi|9P|=m2p`t+b9FZOYHrwA# z*YFX(_-Xs3ET%tx3m{Gd%9;SxSOQ6q8lcYUlk=HO WSxp%97!0O=%wjUx&RD?opAi7eWOl^> delta 500 zcmZp8!rAbIb3zA;!;X}vjXhg?7^AuvnWsPRVpJ7nW`Kd&wh$I81M_snZbo5Sgp{ZQ zTneU|VXr-=*k?(&*!03~Ms=ViGa=MLpn*ts)fyobzU*eSMw0q2fRM86VVn;%<7N+I zHzV`*)LzE?Isp#uvnjD&L5>R^abACU`{42IPq>-Bd)gT5889#yGcxEI=$YynYo{h# zCYqb3nI$Eg8YU%K8YLyAC4#VVvSn(Tfr&|?Nn&bpim?R)0}CU=^u{nISs^zm9#r+DX;p{s6oZRCia&XL8Gr2rMZ{1Pe(RL^ESLq2ZBg`8r8 zl}3i!CuK4HVdP|HU(iqGb zl7M_u216j71Qa&{(`i6?5FbPvgVm=3*#-|Nt&67p`}SuvVqz5GqH?+I9Q%UPx~?bOd4Z=z^V4= zGi{!hK0E*V?`$oVTs2)Mov}gzD$4T{Dhg6xcrrbIwoak>ne__V+mEC(o?_x;VPFNj znPvO*EXGnsMw9KlIgI&?!6ppa3?>Yw3`Pve45=N00E#>tkcX$B@Hi6)7u$tlJb+t0)@{^4LTpS<$Z^fPIU0Rq!>lTZFI z*ylWF>9+Ijv-fgN*GXrrP=JaamVt@_)fc@Axq40V)G7aDAJK;GN75NjF>x|8u!7yp zynT8WV<{t}@pj%E#(c(LV+L)8REA^*ONK-Sa|Tm}GzK$xs0Z)#teE42HOSm7(XyhG~n3ISitz75diA& BXyE_= diff --git a/jackify/engine/Wabbajack.Compression.Zip.dll b/jackify/engine/Wabbajack.Compression.Zip.dll index 4e4b25018231d50019e885aee8c171cce1b4360e..a1a6885f39bf2898e0449a7a7dadf7dcb2035afd 100644 GIT binary patch delta 309 zcmZpe!q_l{aY6@6ftBC3jXg$Mj4YG)YpF7_Y<{Zso>yRoqSubhzxj^s%M}`)JgIKo z{KR5CtGS7u0Rw|EBZHoSo~fRRwuz}xa;jN!qOpOQp;20rp_ye;vW2N}YO1l3iG@j; znTesLNm8Yw3`Pve z45)uaiRgoW=4X-U85#5p^i1`PwNsNV6U|N2%#xB#4U>{Ajgpem5<%EF*)lcFz{Dic zBr!EP#n@u=DZ5jQEUCx6-cO$45FlVJnDnxmi_v4N7z_K8+8cb6e>hYqzywX9f*{rR zc8geSFt)Uh=ruK)Ji+lFCo=;p0|+p0p6*o2$Y{Ko*Cn4Z*qA|^A(bJS!IB}7!JNUA zA&tR|AqmJgWiSNNNkDNUFr5aJ2k}9)F<5;nkZr(V0)&ZR3^FAJC}%PGysIgzF@qk1 T!Dd0X4~!EHI5snS{AUCJ>+xE9 diff --git a/jackify/engine/Wabbajack.Configuration.dll b/jackify/engine/Wabbajack.Configuration.dll index bb270cf476adddcc052c60019527c2cd4023fe20..cf9fc54d56e994dd3e59af86e6a80d354ce0630f 100644 GIT binary patch delta 289 zcmZorXi%8Y!E)aA=dz7GcFc?{n@=+{G6~c#VBhhgeZR|ohZ$?lZRTH^e1~yP_=)NhMVN1rrB3h zbxg81Utr(I#L2?I3O0*n^K`CUMn;p(tUSq#!6ppa3?>Yw3`Pve45h($ delta 289 zcmZorXi%8Y!P2?4=f}n#J7z}a&8L|enFJEdUdk!eDf!Q1-xs>>STD!qJFNR1jr9x| z7>pSi^bGV&^^CPslPwd?P1DShl1&Yhk}QpqlF|}E*f`lTHO;`pB+(=>H95uDf&mPC z__XIuzQYzEaCwIWyTmSek7;|@*?xcee|)kHdx-*6Fe3~q2vQwT-I_bGa&PN`9@UJ^ z7ufePaWXTog3V&yJe@0-kEgE@mKLmGn_LlTg0%3uhj tlYrtzU^)#b58{JpW3c*EAlrb!1PBws7-UKcP|jlVZeCNM@gI5lnE-ELQ6K;S diff --git a/jackify/engine/Wabbajack.DTOs.dll b/jackify/engine/Wabbajack.DTOs.dll index 12a617cd1f984946b37a4e9864106f48b6e9bef6..63a4b07a791f70e7cda59fc13419642637c82535 100644 GIT binary patch delta 332 zcmZp;!O?JoV?qauiM{=^#-6P`j0)b2EYsI|Gy00MFu;I$Foea*z_Oj+hcR}gKu@Vo z^P0Ioo%cRlRv~b9O~Lk>uZ%xs%uVzR7#NHh8T1VFO!Z8(O-zlFQ_YeSjSb8Ujna|~ z%`B6WEliD5Q;m&GEKJhOObjhel9CO~wyzOlnjpaPcT?H%=?=@la8qdiU9SUSD#y{OD*mh}^hcNrfqgiIata73^G=?bFqm zd>I){wm(&8GG+`mVbEqUVK8MdVn}94WiSJhi44XJ20)e}P%Moh2}qkUSOV!}1`D7% oW1y;3AZY~DWdX!#Kv@%@8cQGvQUlaEJzJB>6llRhO{RDz0LuGXM*si- delta 332 zcmZp;!O?JoV?qZ@=bfZojXhg?7!|x3nWwMyX7m+hW`F_nU4`b{~0anx7 z3pUUDcUEFfbT1GUyrTnd%v9rzTq_nwzGXB_*30CM8)K zB_*XLg0OM2Wonv%iAkbKVrp`VvBmZ^LQE3`Sl%Ub%$)9^%oHH-)L%_@X_SOZ-yMTq z?bQ7rr!P=uN>YG|KJkN!g4AE+5IkA!rXzaUy)kmTk_uA}6DKnRE7-Zr+o!8B`7$yZ zZ-1)JWXu?B%%IJX%8<-p$&kok&S1)r#$d*f1mv4C7y{`eptup3P6Nt=_#oOCtUeXU eHefIT!bC6znUVsOvzVT($z%$&V4)^cJQDyiw_6hc diff --git a/jackify/engine/Wabbajack.Downloaders.Bethesda.dll b/jackify/engine/Wabbajack.Downloaders.Bethesda.dll index 48c0bb43e4d23a9aee3fd6276b8400ad8c3c5c65..c944a3743d9065c11ab87653b7a0592131e01c66 100644 GIT binary patch delta 420 zcmZpez}PT>aY6@6Lps}ujXhfw7+EGeDyrJDFu;H=H-yE?fDn7ig(=p{3m2QbSkYP( zp*oQfA@f<$8fb&Ql0TCCdxZSv$x4j^0%xBuIl!u$>9{vUbjNGPzi&3H7%yNqH_}YMd*~Iz@Bg>8HuJb3$*air6-I*r$FJqr;pZkqqyA4&>C)e0EDL@5P`JjS8)$-iU zj*HKjUbvie^y&T0a&`qwoGc8iV9&5@p6<}g$Y`=z)oC(gunB`Ug9(EvgAqeALn?zA zkW6GSW-tJ<41r>43`s!RjKLB}Co@<8)fod-r2aY6@+<>kOd8+*1WFfvbeR8+NPW`F@*ZU~E&0U`F33sbC@7cMq=v7)so zLUke|LgurgHP8lqC4VIO_XzpTla(3;1YVuAdtUhIzGLg@y*YAw8DDQ!F?g9#8Of-%UH6rh~NWNsHzpz)3_`e`qw)qUj06g$p?CN{ay))UDMlR44kfm}^qOtJG&X9Il!kpz;$?3n6& zu&Dbkgr+VI$WO##Nd^|pWo&4gf!qyP)cr>U=H}^kH>Cu`e{8Zb4^VQMb7{lIsB7mM zHaA2{@tB+F889#yGcxEI=$Y!7Xq%WCC8wGtCmI`=85*S}8JbxpC0m#pr=}VknOK;l znVA?`nj|F~m~Fn8s?W@_bIrN?lUXtY1irk_{}{in&TU%i)mfHn4|7gV$!t@A3WmW1 zL8_Y~s#LGOe)HgZlcnNjuB;X&P8J4846|&Wo->t^(PXo3-h9Sj69#Pt69!WTBZg## zR0cC3naE(wU;tzp0>#o8l7O@sgC&qoX0QONGX|ap ZljRFcSxp%97z{QG7Oi01%vkiF5dd&;ZvOxP delta 686 zcmZp8!PxMEaY6^ngokApH}* z_pF#=cM&GQ)wMy50h$UI`^b-}_c#xl*yKW6Pb4=?=0uYRay5A|#m=)}iV3r0iuqts z_gx50T^x{~h{ci&ESk&M&@=*md83^r!aW=Lg7X0T*PWH4ti zWk_Q%V@Lw>O&JVR-q+{G_iA!NCf_k_Qh*9_-h&E)RL@prjN?n)KKIU*!-1RY%mkP? zSr}Nsu4UOg-J+L~(PXo#)nvwC69#Pt69!WTBZg##R0cC3naE(wU;tzp0>#o8l7O@s vgC&qoX0QONGX|apleuk7fyO)9>}Li5SmOieQ|F-bH@OifNPwwS!fWDkpZz{@w2YfJ+K zN*em|-bm#6tV;coYsEA3z~nooO$t!KKUq*gkm>~n!m_p6#Z3a`9rBy&%mkP?nHgBY zu4Udl-J+L~(Rj0})nvwCV+L)8REA^*ONK-Sa|Tm}GzK$|Nt&67p`}SuvVqy= z+g39fSw6SrUY=ZG6Clv>hj(J_QcmY3)}NcXDp%c@e8Z+g0V??U2~-fMI{5kOsr@Ds zm5!TFa+KTLV9UqI$-=-2b|}l{>2^~Y8BI3pI?QJbHet|aFkvudFk(n%NM$esl8Fq) z3t)MWw0X+T*Mpc+dc2~q>pIa%Jxl+}bm RkHKKGpvwxz&5SPp83BdjU>^Vg delta 405 zcmZqZVQlDOoY28CRb|HOjXf%IjLefa%2|psGr&MC7lg&iz&!cCoHbD1T3%ffA)mtt zmxpO#Q09halNy9!~my?4151|61EdOP`sO@{(hP`e5$2vTij=i4k-UvTrM zZ+XM!23tNxPG$yHutS+QPq&-O$Y{J-*I_EgE@mKLmGn_LlTg0 z%3uhjlYrtzU^)#b58{JpW3c*EAlrb!1PBws7-UKcP|jkqypt)bF@qk1!Dd016^xr1 IUH&ry041x-4-FA#R!*& zX<=yR!W8R5GACaKXwK&8GLHNL;tQH2_BqJ<&9(FOKXvKVoXsz^Pq5jV=ov6D7&9{H z8R(hnnP{7s8YQQiB_|pim>C+SB^jDoCM8>#8mFcj8<|*`q?wr*TACy!8<;UL*fBC# zPTpv&wOQP_k&(rHrT6;Db4&sR3=dqf4SQ3+MaX_Nn$R4G6O^G%?FK-K!z zjoK=*0madxFL@VDo@V-klZAm5>=&SO&1xALO*V^KR5J#fFlaNFFqkqJF(fmjGME9$ zLM1fVpsWc{jU|u-sR8Po{Mgcz U)r3Kh!Cg=G%=uGSZC9lUGWsiZV06z&B0^i8I1_nDu2FuAC zjkPw58#gkt2&b-?I(d#sfIuB{{hODQw|Y){%ir>(WbvoTe@vR?0D>=Si~s-t diff --git a/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll b/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll index 63a3052a1dc863556e31330405860f9f9126ecbe..e073ea85bfb7e45bf5892e3c967c325e54f6e8da 100644 GIT binary patch delta 405 zcmZpe!qhN@X+j4}n&;o|8+$Z-8CfQ8^;H#RVSoWAVF-(rfo1Z0Uu&Sewx7BtLjEoz zTpp%{K~V%QHo4lb8_6^dLAcCjX@64%0T&Mo_xS5=?)!x~+G7|adp0NJPv$l^(KBFR zFlJ=XGte{DGto9NHA+r3OHMR4Ff%ktOENUGOiH#eHBL=6HZrj=Ni#Dsv@}UdHZa?) zRb9uz@?)9FqRDIO0|X4_AK!lNHjmSm=-I7%?JOiF^EAv-fC?s+Km~!Sl``~RFP$j( z^@QWEl(Nmo8cr~BvM{iM9m=wKdedG;Mw88qiO&GKpOc+cVj2My`QW?yEWFmtx zg8`6b2oy_WNCMJk43z@67?Vy79DIkvAqSEp_F{@~_>{K?$r#(D+} z491KMdIox?ddAwR$(D)crfFtL$)<)$NtQ-QNok27Y@BSFnr2{Pl4z2cnw(;6v01CS zj)f(E)}HpsYw808f`#JsgH_G^R_I1RU<@)P1t@1Rxw*}h)tEt#!Cj!E$Gp^1_Whi#QlrChK#m+OjaffE|>{%773{fl4BaJwd43Jdv}TRe))2 zM0Ww-8lTzvvY)^Fvd-D8!~cmf$VAV8fx(!OLC-+XRL?})#MCG`)hs#D*uc!tC@sm* z%rYt2!qhl5)!4|y!X(Yi#L&_tDcQh`fq|8gfr){EgOPz7<}x6C<+a4o$pwM|0t^z{ zmrR{>%6F!f%~H`DQp%Gr2(~Fe1!X0nfnaap$vRPMbK4Y*6gEoT+gDHa%Lo!1ugBg%aWH4qh0J02$VrdLXK-!GK5=bXA zSOC=-168F0Nh6>x3m{Gd%9;SxSOQ6q8lcX}@)D-3CJcHE2Ac(?Rxoa6l={yI0HD`X AVE_OC delta 382 zcmZp$Xt0>j!Sa0S^8Fio7I82#PuAyDwPj|20Xr55i<$U zzKuOw4@!6~+b6VMroudSvkw0!#vo%o0|o|TMg~0tJyShn?bKw;M03+Lv!rBG!=xlj zqoky?L=ZMkwoFYkFfmCqNlZ;nF}7e}U}a=rVqoB4WZ;Im3`l1v`23$-AQ&K^zA9h+ z-HPYFYhM@e+wT6ma`FYiHU+34uP#&&qEIfE%f8iN@_5|D4oUfZ|4AIt?ff;)7^o ru=-RW+kn9Y2ou2=WJ(H9&SJ8>gej{rgC2vyWa}zxS1_onB20a5k zQ#})H6H}w)RI}tnV*@ioqqHPLGs~o83sd9NRAVC(3zIZ66GKarq+|oL$xdQwEDwJ? z_&hmBEI`0B()`wnAQ9IsPuz-&{>f!eJ|kAA02K^{34&C&n0$%5jig7G%bJmSb0n8Cr@*(cmx@1by!Bbd?cBL)>&ok!dxYzm%#HO77#NHh8T1VF zO!bVlQ#3a!qF*P~G*kZDim>P@X)TBp~ zbHoA!0xvAuBBo#Lw;<*GW$8bg>n5KOt5bjq{(Arw1gYM1@y8^Fd2YHhGj2C+&JtH) zb!vQq7g!Nv^Q45 qf$21$JctjXjlt?ufouZ?6Cg|kV~{B+Ksk%a-=$4~#_P&lW(EL23Sl7t diff --git a/jackify/engine/Wabbajack.Downloaders.MediaFire.dll b/jackify/engine/Wabbajack.Downloaders.MediaFire.dll index 0518cd0d3565b576aab67f4b12155f7ad88082a0..cb59cf70f47631cc37e2284dbcf49b1aba99f74b 100644 GIT binary patch delta 387 zcmZpuX{edd!Th;Le`EK3X-1aG*)pm?a-xi|C<_A&tmcHUSQ%I*@079DM94%k!ewBJ z8D??8#U{(j`ZKa@&XvvP6Ij%3;;?9|k@G6XvYp&l{i-*=(0;;ZYocesz+lYCpl6_G zs%N5YVrrC}YL=X6Y+z<+l$K;@W|@?1VQQS3YHVa;VUlKMVrXfSlx$$ez!1R5;63@D zy4d6bWA)8$#zu@R5BC4uKDovuKtTC~Z%e?=6zApIrXtLH4yaAOW747k6)aAN3W8Lt zb6;S7QJ(!UZNH2D<_1$fMotz6R~vefa;8as#1ZZ5m1)}5T^lUO@L}Ffh0%`Q0HWB MD^sBHj#m4b0eECv*Z=?k delta 387 zcmZpuX{edd!K}Zhdt>)~X-4MB*)pm?a-xi|C^G{LtmcHUSQ(fn@079DM94%k!ewBJ z8D??8#U{(j`ZF?b&XvvP6R>92aB%Rpb81Z7up3=o#pl z>KSXNCR-+&o2Hp1C7T*1C0QCJC8Z^TuyL|wYMOzGNuo(&YI2IP1p`9>BZK$kf9hhB z3yjq_yBQlXvRHneyLfVqNr1ppr5A^r^%FcM*ymL}Ijg}s`Ho480#tCt1*jlM^#;>^ z1>3%E!pb%)knEJoD!1X1$Dz#+y|wCNl;bGiWoUG9)utG9)sXGng`@ zF_wA#-M E0H0!D{r~^~ diff --git a/jackify/engine/Wabbajack.Downloaders.Mega.dll b/jackify/engine/Wabbajack.Downloaders.Mega.dll index e9eed04095e38ba5023a954bf746dd9924d13c25..47507b2d16b7cdf4c35a3ce45a92d5ad8e15814c 100644 GIT binary patch delta 376 zcmZo@U~Fh$oY2AYEbn0R#vVO6MwZFDLpfm}`BlyuNE*ruYqBuFKqw=G#mayX ztLK7?P0p3~2kO}?ug=J_`Ki1-pFjjxnA*=ZfnF=L`H~a3b*eXu=v`nlH_V8H^Z`8B!U{fMgLpfm}`BlyuNE*ruYceyyKqw=G#mayX ztLK7?P0p3~2kO}?ug=K4`Ki1-pTI)DLcXZR#~$0BuQ;`my=lQ_5xom+=Eiyk3=GDM z40;B7rh3NOsmYd!=B8<8Ny(;$NlBJQNl9smAZ(m$nVM!`Vv=Z*n3|koY_VC|w33lU ze)EN;lV_L(2pr-m;F>(=q+{EbFRx!m?Pr?&!>mdHD!7mhDhN`&S>)-qn-w*s#gnBM zOrB)^hm)Cs73^l_&C@Mv85xZ?i&|AP1{*VIGo&&kGgvYtGMF=%GNdt>F(d)`rVNHa zIteIl1g6t~@*qBlHU_It1+onoOn@*Ej6tTP0Oc$uKejeyHD=IbFxV_;_k?jXquqZ- E00ir2S^xk5 diff --git a/jackify/engine/Wabbajack.Downloaders.ModDB.dll b/jackify/engine/Wabbajack.Downloaders.ModDB.dll index 2859328923cd690da77a0f080580731d55122737..b2175fff3a341833e114ca8f74df1afa94ffc2dd 100644 GIT binary patch delta 405 zcmZpe!Pqc^aY6^ngE;>^8+$G(GO|pLS5nnvVSs^^JP;Nu141kbAvSrfk~NZQYbH$9 zj=XTO$%4xMKpPU3tr=N1&sF9Z7O4B8aIh|o+hJWS^U?=TicW3bW0uEhZlY(vz+lYC zpl6_Gs%N5YVrrC}YL=X6Y+z<+l$K;@W|@?1VQQS3YHVa;VUlKMVrXfSlx$$OInkk* zk)_&HW6tCojsXI)c`i>|UkmzozwC}kun*3iY~fU+02Ms%4HX2b-n{U1}K_O*V_VRWk;gFlaNFFqkqJF(fmjGME9$LM1fVpsWc{jU|u-sR8Po{Mg-;)r3Kh!CKu69OieQ|F-bH@OifNPw%DBLP|V13 zQt~?PZ*7;Z&ml6%;@!> F5de}MWi$W) diff --git a/jackify/engine/Wabbajack.Downloaders.Nexus.dll b/jackify/engine/Wabbajack.Downloaders.Nexus.dll index eed3ed534340725b4392de26645d74d514a09eeb..6b7ce55d2140346eb53991661d90ef927a8e7f96 100644 GIT binary patch delta 390 zcmZo@VQgq&oY28y&Zu^KV^5kSBg^E^lB$|43^4G93&LV$K!`0xh)s5sQU~hE1d|h` zghdgmW^u!HPTmQU-~3SOyQn~Dbdu_%^PN5`Qq)7rHXc5^`Gr9oo4JXe0Rw|EBZHoS zo~fRRwuz}xa;jN!qOpOQp;20rp_ye;vW2N}YO1l3iG@j;nTesLNm8nv{XO4r^~)JgpC$&2Og>^!qW~40mkkvJsqS08a_OB3+7a7~S&KI( zSn4ryvM{iMUCXk0x>YSBqseAbn`*{j69#Pt69!WTBZg##R0cC3naE(wU;tzp0>#o8 zl7O@sgC&qoX0QONGX|aplONlfvYIgHF&JzXba=wJ JnbF}tBLG*SV9fvk delta 390 zcmZo@VQgq&oY29N5O(D4#-21uM&`+%B~>+<8DQWI7lg&ifDl`X5S#2Mr4H1S2_`2> z35z0B&EkgZoV*hxzxkomcTs`&4T9Gf^2~Ht=3~yq*H-vt^9zGGHgjV=0|o|TMg~0t zJyShn?bKw;M03+Lv!rBG!=xljqoky?L=ZMkwoFYkFfmCqNlZ;nF}9doYyOyJOP0rj z$te~A0z7#ux?64@??Ed*|dM7Bvb`!KiAeAW(I}m@y;)`KAnpKspI1 zZUm;&fbt+dh&BeRPX)3K7)*dL5sX2mqyXhCCO@_{Wi@8dV=&k(=z-$((4rcwCfPul7kwMQu&s5Ju+r-o;In^vV(b&Mu&?qg*(9AL^ z*}~K~HPzV2#KI)a%*4>rBq`azY%`Zu6eEk$jvtpMXJ`iqXgG;Ft~*fXx*=$_#H(EK zKa)>rPf>shh8jQxfvSU)k|cbtm)T8!BlJgpbB&G=6DJD;E7-X#o2TooWn?tj>}qhB zG1!Dbo56&^l);E0nIV~vefa;8as#1ZZ5m1)} c5T^lUO@L}Ffh0%`Q0L@mBU7O96OHnj0X4KzDgXcg delta 321 zcmZq3X~>z-$$Wm@wT(StVvNj_AB(AqGBdzHDm#S5%D_BXR@|SFd2_C~HJ?CpvCf1K zMe}^SmMSjd;9TOjd55Yri@C9$0Rw|EBZHoSo~fR(c51R^qPc0BSyHm8VN#N%QBqP` zA_yBNTc)NNn3yD*B&H^(7+Y-S(u!had0SL)d~$|%fWTXWgik*U&pP!f-I&y#zcO?3 z3GFEgP{ATTs31tS#o8O~ZWsTUJ9y+vY_8D}V&Y_GUm@y;)`KAnpKspI1ZUm;&fbt+dh&BeRPX)3K7)*dL5sX2m TqyXhCCPy2Y0*#+&l+O$R=NMaf diff --git a/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll b/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll index 8afb2d5c6055272d833071d9e609e44ce8b09e89..ec243adf5bce296487a2394a4b2a3558ab26734c 100644 GIT binary patch delta 415 zcmZoTz}RqraY6^n^*5i^ZtO8JWMr9q&`?#Bg#iXKxFIZ72A0Xc4b_42%0~X02>Ek7 zaCw*(hIA&l*yPzp)<~u)B23%-+DK7Upr&|QdyHkNdxHA zs@BM;IO2T#SW)z+zYk7smJ2UnYQvGW6Em6pvPdaSuk!2<7URV|BL|nl4N!O delta 415 zcmZoTz}RqraY6?RTk-iN8+%L)8JQ;^G*lI3W`KbVZU~E&fqC+8Lv^6MvXQ?gLjD{N zTpp%{A)N^>HhH#@HIiwH2-7ydHc}K7=x1Y%l$130ne*|gOvyHZH=AWVCUDsr>lrXG z7&9{H8R(hn8EdB|TPB*DrkN!rn;Ir1SsEoJr6q!}ak6Dd|CVC{A7`^0D;1Q85tMXtaVvc$ho56TH=ezC1G6(P{Av&pn^cv z{+tt>ZBolzKR;>zvSzbfcmX3PGXpEwGt8T(N6ckpG~R3)wVE;5m_eH%l_8nIk|B}7 zoWYbKjlql|3CK5PFa**`Kyf25od%Q#@jNXGZRk#GMwZPJcYY2M*tenKRP-`-r>^!$0|vjot=p&YFg}nrH_5{cS4sYw^WBM2 z@XzPI{+R6r28?n{oGc8iV3)IOpKipM%gAW5oz;XfnK9UeL7TyZ!IZ&>A(grmMB0)m%v0p>c4%Kd~`~`@sBO< zTc7O(28?n{oXiZYV3#v*pKipM%gAWFoz;XfnK9UyL7O3!A(_FFA(6qH!IUA5!HgjZ z$Twv$1ky=BaU(FD29yW!L9{VgeJYS`z+eJ|iC_#eB?TyFF@3iwqbbmWkEV?LOaS^o BZdm{T diff --git a/jackify/engine/Wabbajack.Hashing.PHash.dll b/jackify/engine/Wabbajack.Hashing.PHash.dll index 134074482a1e04be24dd588b778db485f9366519..4570b34f8edb516b5ad638f443232645e98633e8 100644 GIT binary patch delta 334 zcmZqZU~cGOp3uR^yWD8T{)Kp_56AP0xGZRBglcZz=v&sLqI&A*F^(za@QqIG@lRxYV5Lop2 zm{al7LtfqKj5Y6)Lherv*j=Ik736{mf>dA0&VTDT_4Bi%(HYY>KiR#CiIata73^@9 z&C~bgGBTQMW<8M17;M6z&0xY{%3#Eh%#g}p1|$<1j2R4oEJL7J8bcD0He;{^(#Z@K pKy}7IRjEMI2&l^fh|_?wCO|coKoX<|sB`k}gQh^^KOW>~0suUvW)1)V delta 334 zcmZqZU~cGOp3uP};nKfgV-H(CBlG0R`KqGK3^1TA0%5T-Fi$?4pA3{YEU;!|-ke)t zZ6Z+4n92I?WT?v|<>I;LElOB6?^tp|z}#5RfPul7kwMQu&s5J?J2lxd(cCo6EGgO4 zFe%B>C@Co|5rmDCEmPABOiU6@5>t~?j4dYr+v>3S`_`{4EYg)To4v31Iy-aeOUp4GJ}$xtKS@T z>yvzw7U}X=WydmL^Hb24)Nlc8m;`lM6hgHvjM_VPrY+$?^E)0AD($K(rMRSHnSDk-QSNcBlMU5{jM?xlJQ^5r*Yd8;sTvM{iMoy@X%x=$@5 zqseAbziP%{69#Pt69!WTBZg##R0cC3naE(wU;tzp0>#o8l7O@sgC&qoX0QONGX|aplOOw=vYIgHF&JzX40^)2nK9@;BLE;pT0j5* delta 337 zcmZoz!Pu~ZaY6^n+LMQ$Z0tFr$H+W6P+wJ)nE?j&a6wqC49uIm^<@PFp5MI2Z&)be z(R%LF1Bbod>YI1izvQqr)-zyWFlJ=XGte{DGuBQ`woEiPO*2bMHZ@F2vNTFcN=pP` z<7CU!Gy@ZpM3cnS

8I1_nDu2Fu9>9#Wfsc$6@*v}9QyoLt})AW*^d!s$<2w969P zXrmqeZqFxQ@TyXP3NC>O0#!Tils$8=fML$ouTTFrZO-ymVdP|HU&5DZ7oB|eeW=-Hpd*WqP^Qh}rqP?rS|rvYV6fNCs(BuEWV=j6$jrmQ9m RdJG1W9ZgI&Gg|#;1OUM>RjmL3 delta 298 zcmZpuXsDRb!4fUEZ_37=DGH3tn-vwEIR!q&TAXX){_4F#3a!qF*P~G*kUuA z!80b74QDp5pZvinK;ThGy51GPmHyj0RZ@#TW_C;tFwRnd3eGo!3IbK@ub12AG5ht- zHGekt9Nc`%cncFJGXpCF2rzG+ZWhbPXuSEWxi@35F@rWkDnl}ZB|{>EIfE%f8iN@_ z5|D4oUfZ|4AIt?ff;)7^ou=-RW+kn9Y2ou2=WJ(H9&SLUpOH)>320aFY$&Mx_ Kn;EVCGXelLV_YWy diff --git a/jackify/engine/Wabbajack.Installer.dll b/jackify/engine/Wabbajack.Installer.dll index 2eb68fdfbd03f3c2845ae82a2c45157583714d98..a18b60d41bafd97af18f547ebde98efdcc2dcbbc 100644 GIT binary patch delta 35430 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�c)uv^ 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 bsKaE!>A(cs01AvetL=hDOz#;d8n6QZ>17Z8 delta 35596 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 z zIg1!JGHhn)&5X+o+nKYS;X};X&9IX>uQGg%Iqx#;Va_RrA2H_}gq~k>LE?Te)JfD2 zGs9@+#4wCwPA0<==9Dq4V9sQQwai(-a1nEsB7Cc;k@05cuV#2Nb7X>^+;)iq*v^9O z3?E|gZibyK?PUI|3}0jZy9|4n)64J_!;hH%4MWh7WzcZ<80s|KJ%-UN^+dBEhG85F zG8vXIr;K3*b0#yaWzGVIiV$L@VHCpO{PRqU%Y^y88x1V2gGnw!x#AGJ!M*_#Rk(HadVhh973~y$>%&?s~ z9SpZKe2DqG8Fn(Ki{YybUt|8e411W<%kUJ#kC^`rK~FB|IOYs>I`*4kG;?AY#xW<8 zVHtBO7_Vh~0mDViX=Z#i!<(7Y&iHnQ4>6~c@mCoxe~nqEn9%6Sl^BL4`Yo_vbQ$Bd zh;JLcfbrD~+mUl}^mfK~=xH1~krP(%Dsy@epHXm%agBj&MjN)kU2T!Y_&WYp+h{EC z=1eE@4i$8<{8ffM$nh@hWgNV=z{En~#kCQ?x6sCTDZ@I3;JpR@Ss0CQSWyYW8AY`S z?I!9E{wUksD*&#&`+irHt1yUdMPd<1LK0Gv2{?C*xg+WAgVf(aQqx;|Bbw0UN_o zKk7%F-*%XYkoe7hTj0H-7Ur}tr-Shh#=98rV!Q`6jm5pp>17W1Z-H^e!hbu=suKQa z&66ALzYp#xwlT{_tlUz>msOQAUW*kD7T2+~jycW9(U!C@r-eBkjCU~JNez^AG2Vmt z-6g#!ZK>*I4g{duojuBy=P7@_s zrwPMb+GV1;y(Tg$f@HX}sx*l3I)=?b)R`8>+Zpd*yo+HEb9xz%Hj}2!Om$-!mN2K5 zVKc)P=C^y8=w#T#u-8mZfQ3v53*pg>+bm?Rgz-|wYZ5a;WmU@s%pcrEl+MU6}jyUJHn}f z9>#mZx4?uky<7nzcs~)b1s)qCA~+Jr!GI&jP-bIJDZ@I3Eety%WY}5N$(miv>0$gd z;yCuh$bu&ti@Bqthi!p{WhDroD=QsF4b(AQgYd1Q4#qpVLMQ5Ymv=FzhdI5BpB_f> zJ3UN>ud2XGriGR4lv=6Bp1xPnA5M5J!@A*Qp`Gy##=98y4EJyaCPWl3rBU2i6!C4y znOJ3uqA-@Sw3P8$mbNqOV*MV*MKsrq=DLj6GTy>?JL4UU_b}cYEqgFH(KfDNqsny* z7b1MCsM$unZ(&Xc!!CwB%<$p{m#z9y#W;FzjHsgJCyHzpCnG9Ae1=#BKpgg@~oG zk7iCZa#mKJ_%^~Ul82CKbXbs9Nu6_vzMdX~g(0Y_zB993&Z z4#6$ptZZkz9q~6Sdl>JHqb4DqA}ZoZKbrAU#_Je1GpC)Qr-NCYOms2c!#IrKK^Z~j zN*FI4!Ob(?!Te6f!9kiO3`-rPU*}*==CmN^<*H7`yO`h05E9r$PXejh7?v=rl<_)- z&CF?Gyn|sUbGjJsWeACED3R?jEMZPF!w%+jGTy~FB(Z)H>nC}rC}CC|vzi(2WZ1== zUWSm&y<}LIOtqRBZ(+QHVJCBXlX(bINZ*#i_8G5ZyqWP9#=BDZEmWkEQYphahP|m| zLZlJ2F|11?oeqYj>4dj1>|of%u$Q68U~3FpGHB{{Fy5u`NAduTq@=Dx-1Bl(*GR6A z$wo7&LNDWWSlpSjEG;5_7sFnLqL?_P4C@%SFzhI1w~A>E?PZRwgf$t~G3;V!D`h^z zIu92e40}s?_A@TVkdlpI9m6h$y$nmsNZL|HO?ENf%TSbaErz8G>&j_BTM%Yfbug!w zp{QUdDm+xwRj`;@T?}oNEM{28u%(i8L=`u{u#RC1Ls89k7`8C%VA#dbRzvA#t6`fA z>ln7wtWL(}JDBLIp$2*xim|LZma3I9UPrKP+ywEf$Be&_HztH3)Radee6ZS%@L(0e z#bxmba~S@TnS^*u8UEwBa4sto;ajDI?<&M=^sp(t3SoH8ID}9&3E}HS(-D4Jcsat4 z9E;BLL(&NUIiFzNnB^0(cr0cG!dV5^B0QE&f|zhO!U-wVWJfu{uM()SHyGZ{a9S+6 zIF|7{D%k0Y)q6uP5e+(z=KL=olz$`hJP94y_4tDj`qROn))V-w`aE`)zME_(e~TQlhj`+4Xu33 zA2ghUKU(~4-DQ=;yRhL`F6bPGXm|E<`~@8S>PUzGDS!L3SMj)ASUGCYnYNB;7``(8 zVa zoGyOxo{PULrC+Hn-CH(8)Qs?+*w9pQRl_w)uGqWQBSt&+cJ36XM+H7ob+=!DHoa!g zGwsuArmvc|chA4+P+P0tE5fCBw7Hq77zF(FYHE6Fc4}tQq6PVNx!H?z<}b+3m_I*% z*&AC1^J5>r)Oo=Wz{WOurM=kug`GlMI*)KR*Q2thza;l%LD!p1^;i|H4@+z zd&HDKa?*_djlln&M%mMC-zG{ifulPY>FzBsO?>jZsr~`48Q~TVL%GRR+y+L#>C^b{hl^(25UN>9#aD+GcbrifO-3L zJ*HSjM&s>Y^_jdGgN+%qf!AUu1GoMp0vBGHGNdt>F(d)`rob&hNkC}HU<9Vqfbt+d zh&BeRPX)3K7)*dL5sX2mqyXhCrcX9xGG#Sp&|@%|E~vv~!s!6qa0|3r$7(x}`;8F* DuBQSR diff --git a/jackify/engine/Wabbajack.Networking.BethesdaNet.dll b/jackify/engine/Wabbajack.Networking.BethesdaNet.dll index 5b7f2c2d3b5454a871b3aa0e36e4add442ee9f02..7b6c4a8a66dc797e133802ccb42666cf8a5e0894 100644 GIT binary patch delta 343 zcmZqJ!PKyWX+j4}?7XD28+(dE7+EI&4^b6mVSoV<83>D&fn~B~=zK<&%{N2aH3UR5 zSKL?And&v6d3k|!x8=^w1$FXVwkCQ83=GDM40;B7rg|pYCZg4=-5!ZaDYZDfZt(-Obx3-eBTnVPFM2 zoMrR$DN`94O*ZRJo6i_*!l2Dy!eGi^#E{I8%3ua06B&#d41g>{pjaA15|B1yumsY{ z3>H9j#z0l6K+*`P%L0hgfU+h)HI_gUqz0&SviuBFRucw227}FllU6WpW}Nk(5dddl BU>g7c delta 343 zcmZqJ!PKyWX+j6fF^So0Hue;SFfvd6AEGMC%m4!-G7uIk1M_6d(D{tan{S4;YY4>o zDIDJSRL^7OS2G6lrKi7cE~t~|vNhH-U|=w2WY9CvGu1QJPEEEm@y;)`KAnpKspI1ZUm;&fbt+d th&BeRPX)3K7)*dL5sX2mqyXhCCd21iB)o5_kQqLU|Rt8FgPW?^Efn7DMtAgXkWOZ>0ID+vs!9ct hMnGK_K%54YH36!z1d<>%K%JAn8=C@+*EPA!3;<32Rxbbm delta 316 zcmZoDXegM_!BV`fae=5 zj_A!x)FN1Hjr9x|7>pSi^bGV&^^CPslPwd?P1DShl1&Yhk}QpqlF|}E*f`lTHO;`p zB+(=>H95uDf`P%2k-=uNqKfF`3EFC#OSD;-SdO*$Y@1x88z8XN!TjYkk9U5(XEq+{ z4=Z>&`HpU#0#xvvFH{hunkAv;Qo6IeuS6uv`^{B)B21jj46F?V@3u&13gnc6KxYyqvTYxDR1m0ImUBX=u-~TXYie#cUf7)Ct;NX6!obP^0xX-S`_wWrnrs&Jt7Z%~ zVbEqUVK8MdVn}94WiSJhi44XJ20)e}P%Moh2}qkUSOV!}1`D7%W1y;3AZY~DWdX!# lKv@%@8cQGvQUlaE`LVw#s|kZ1gTZFOpeKx*8H4^a0szo-UzY#? delta 317 zcmZoz!Pu~ZaY6@6S((w{jXjGr7?~&QYgz)yOigP>=FLkrKZ^+5>X;!M%*pJzrn>mv z^2a85n^)MSvYQ+0889#yGcxEI=$YynYo{h#CYqb3nI$Eg8YU%K8YLyAC4#VVvSn(T zfr&|?Nn&bpim}Dy*KVepE8Si*v&cUxyFNL?D?lKrsj&ITrx5>%ru*X$Fci2=KH*iP z02Q1s2o(gW{(>RWk+~GiWoU zG9)utG9)sXGng`@F_ghGv#Y z$rh%@sj0?BCKe`XW+sM~CP~Q#W|Mz#X|T-xy5#9(7486mzy+dh+yB&i^>(C(PCMJA zHMxbmPXQ{J1rr3SPVHTI?&8W!(b#zN!}6Prcw(41Sr}Ns?qu0Koo^{4qseAlf$faJ zCJfpPCJd$wMhwXesSIX7GLgZU!2rlI1d63GBmrqN21_8F%wPdjXAD%83M7qyx-5V= b4Jc~@RAUJwL27_HC%X%o0*$W~YGwuid#G0X delta 321 zcmZqBY0#O_!E$)r=gS*=POvaCPYz{O1(MaQ>Y~gHFfb9yWMyF9yq)zSvjFpH&+N-5 z6}%P-z6iOt?pp2U6`Ycc=Eiyk3=GDM40;B7rh3NOsmYd!=B8<8Ny(;$NlBJQNl9sm zAZ(m$nVM!`Vv=Z*n3|koY%%!h-Hnquo=tAy z?o)sXazO=wsy)KP47W>7$@;{bdt=jPBc2#0PG$yHu#1^DPv=|8$Y{LTR$x10urY%+ zLn=cugC#>EgE@mKLmGn_LlTg0%3uhjlYrtzU^)#b58{JpW3c*EAlrb!1PBws7-UKc RP|jkqyO1f+_*$W6W&j^TTWbIS diff --git a/jackify/engine/Wabbajack.Networking.Http.dll b/jackify/engine/Wabbajack.Networking.Http.dll index cd60d787e3817a7b601fb7e6259f6bd7bbd5aa0c..0687600eac46b319373a45fd72603a83987092d5 100644 GIT binary patch delta 10131 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 VKM^eh409*`=8n+AO41Vj{{R69+rR(- delta 9525 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

h!hjaIk}22{_NYA~q!8QEZjA;N89jm>Z`U35E; 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 delta 407 zcmZp8z|!!5WkLtbv(DtT8+$}HGBQtIxKR~I9^a_0$;YYMowl1REIfE%f8iN@_5|D4oUfZ|4AIt?ff;)7^ou=-RW+kn9Y2ou2=WJ(H9 S&SHAB4x=g1f{8ke`OE-f|7pbl diff --git a/jackify/engine/Wabbajack.Paths.IO.dll b/jackify/engine/Wabbajack.Paths.IO.dll index c702ef1f14ad58a2abb4fa04e24580245694660e..366ee28c0de686b7ffbe09ca9bdaaf7610f2ee65 100644 GIT binary patch delta 313 zcmZpez|=5-X+j6fg!3;}Z|r#y#KghGv#Y$rh%@sj0?BCKe`X zW+sM~CP~Q#W|Q|;TTIpr72Ir6eS(=~n$FTWlj;Km4l7&v^F)~js4OV4j-aI>4S4P18sh#QlXRCb|G1io4%|BzZ z`9}U7E^}i&0|o|TMg~0tJyShn?bKw;M03+Lv!rBG!=xljqoky?L=ZMkwoFYkFfmCq zNlZ;nF}9ezx7uQ|W~kt1lj;-9EE|h+u1=~C5J=i`$t(B!3FnoM#B`i_+P6+lsLxV> z3M#*Z3IY{}Mptf8;avCGiEDk^)6K8ymoRZMGq5s%0Q2VQO|guO#+$!3douJK( diff --git a/jackify/engine/Wabbajack.Paths.dll b/jackify/engine/Wabbajack.Paths.dll index 019484a477b98a86a982e359d02360780a690473..7ced424100af93f42a7a92f45c942df881512eb8 100644 GIT binary patch delta 291 zcmZqZU~K4MoY2A28#!a^#-1r^j4YcK)!F$3dV=c?YnlIYn6XplX@u%^?#&jag{}X=WydmL^Hb z24<5VSVge#IXB;#Y-1fDkQ}$iY0~{9w~g`zbNBz}n>cxnb&3L1@LC~M5U4ul?1Y*3 zSG)SjzJB>+(PjmkJSI*S237_TVA(v~&XLumGwv2C7O0l14yX7C@W^lr;gWu>_JJH9(z{vmH%= K#xHb?X956M9#bL! delta 291 zcmZqZU~K4MoY29t)b8=;jXhJ;7@0RKs9z z$q%d|SdN|jGjpsLlS@ETi$#bkz6rh67S)hVI)sj8eIv#9} z>^3Y6U#7ZQ!6uK1lbL~)0R)&gPq*`BWHjFV)ZUme*qA|^A(bJS!IB}7!JNUAA&tR| zAqmJgWiSNNNkDNUFr5aJ2k}9)F<5;nkZr(V0)&ZR3^FAJC}%M_+tCzg{6fcgCIBK? BT+ILg diff --git a/jackify/engine/Wabbajack.RateLimiter.dll b/jackify/engine/Wabbajack.RateLimiter.dll index ac44d2511aceeda7531266a5c7743386b2ef1693..2fddc5b757d019fc9cded6a2f6d345c23c1eb99d 100644 GIT binary patch delta 302 zcmZoTz}RqraY6@6cJ$p@8+-nkGqP;%wD1=e=*kwEEFA6Ry6fA<>|-6JBAZ`$w{hB< z=ov6D7&9{H8R(hnnP{7s8YQQiB_|pim>C+SB^jDoCM8>#8mFcj8<|*`q?wr*TACy! z8<;UL=rJ;APJR$7w)tS_Y(|z}dl$D)P6!VWc%dyWD0jTcW#**7w#^SubxuAIUZ4OK z42gsaf>a;xu=Voc_gkXSlV!X)Ai|D`lZAnm0R&h!PmfAvWHj0QH#(d#*n~lw!Gyt- z!H6N5A(g=lNG38EGZ+9_hCs12h9n?u#$XAglNl_4>WqP^Qh}rqP?rS|rvYV6fNCs( UBuEWV=j7$Fra$JcB%PDkALdq1K|Y(^b diff --git a/jackify/engine/Wabbajack.Server.Lib.dll b/jackify/engine/Wabbajack.Server.Lib.dll index c4981acac6d475b374975ac4f8e814acac3e93f9..9ee7a1f21496de7ac9b864eab877d540b5cf0b2b 100644 GIT binary patch delta 327 zcmZoLX)u}4!BR8RbN|MkBz8uY$?w^HMOheNpbyGqWnh~e$e|9DuLO~s7jisj6Ueb| zx^cS2)Nj@X{l<59=c#UsdBW7fEGgG}-)D zG@LQmgh89Zgu#@-h#{FFmB9>1CNdZ^7ywy@K(RE2Bp_|ZUo fmjw`~0cA~qYAk^yNDWZuDHAy~b8h delta 327 zcmZoLX)u}4!BV{<=laH;Bz8vT$?w^HMVT33ppON@Vr5{N9LS*#l&=Jln-_9CXA{`9 z%PrC%U*2oqh0srHl%k|I&)|Q>Xl|@$z`$V4$e?GSXR2qcotkW!Xl|NjmXvI2n3QB` zl$4Z~2*Sq6mZ@n5CMJm{iK)pc#uk%z3Tm^|f73iY`GsJBfN8~)qoBxW2 zGX@(oXfvcTBr{ktBr=#Ym@=d>m@y;)`KAnpKspI1ZUm;&fbt+dh&BeRPX)3K7)*dL X5sX2mqyXhCCNCE^1sZ=>T$u>~MM_wupmOcU|UVH zN`(`gc0Xm^#A9NMOv`afaOb#rNhk_BXVC$A5JzG~=NRDT!86g#J@1o1tL*F_!^tP# z=leYG^Im@Mfdt;ZOIBlZ!M3%Rf!|t4;w1Ll({TYm>wV`u7y^T)iIG)r>IH~lgV2=C@25-p! zfn*@4RPZe*k!GP7c}A#3dZhD^r=_cq3uT*-!!kcIU;YbZ2%c2o;xgNDiPxB)Mg42@ zZ;=%$zLcNk9}ue+KkR*p*BD1td}D`EUp~>q(%&V2@|KQMJ@FKP;)EO|>lBk%QwOdp#o5_a7=4CqX@6=gv$jUQ*DAK-y(flGF4QN&x z4yv(|s7By%(dxGoVwe@?2V0JbNift);z5l$r|r?Eg|2GevJ2v%bSMjI&F1s!R1 zV#O^b-eye^+);M9^;*qz2vX#S8*}f{l)|Y*4sM>>qqznu%M-mWq;AucLr)^YB@JjQ zU>8N7vT@a*rV@sdktr>xse-4I(H_-ajSYUEjPTgS_irgFeL+(LG4lBKUe(Nm3s<7I zG_~-bE71wDba_eb$C~RQSix8F2As8=*W3W($tbMc%j)4QMSi#l=QZ{4B}KgRzr=@@ zml&kl26(cPkN%X6DKoW=@N_cb5Bbl_n@UyMCTOqX75&hfYtzny=abQb!dcq+AhjiO z?S%`)h83>TgX+a#sUZ(%!3zsBDC&c^VXbyC*eIgQA&#xku{*+NSG>U3LGg)|I&Zgj z4fHPLgZrRa?$>(2*qn&Q=<+oN3v+mcbK5Z11Ty~*f;5Y^rW zZziKn(lKo(oJ~fbOFzd8$t!{`=u{Vj8*a3gLk4u`mqRXikviDUKdi79Sqjf1tKmFy zHvZdK4h>*J&Vw@K&E&TstFhfyT@ya8&FY&;yPyY~x}g&~jBc9DL+qlgpRyw95a}VR z9HJU;gsdoWloF$qc#{Ui$d41pDLYQtaoWcjTEv%B35+j7V0;k*<2#lyzC;uGR>t>T zM1DOv4OH3SC98?pP6-d`F6367kDLdv(LBOZt(eYavP-(K%&kC&x|B8fhQMtbL?|m@O?B z;@X=(EFFQ9mSIe1TO*jbw;+Z*Trf_hAPa!XD9C7f89#iJj4#MaP6N>;ml^_TTJP^e>>5Y&ie#Ohe!{nd;6f;8l@mk zgbY5VIfIuvGkELvi~zKmUE~Bac)JkkC}m^hn5NJ+rtneiq+O)_#2`5#(lOF;(xB+^ zVmqeb)0hqMmd+(M66QYaX&h6nvUEIgrUU%jETDVm&!d(k`Np9DgS7AI#+a zL*z$D$B=mc<79o3$*$A@Tw(7PMjRbri;w!HJY>eG=<0xV?8CDJMo;7d7xpWG*1}bocVT1bSyH zCuXhCUKtbquva^SIl>p8E|xaip$2UD?al)ZqzkH{5>*v?9k3cd9Z-v(ZnzffsxU4e#v=eRki^7JBoHh$rUJbJ_mWtC+$W%hD=jm_^{fBp@*zx!8YZ+eyThM|j7{K=(=kPt2 zcut%Q-}svs_%Bj+9^XgBIb)V=$H(`!F71B5`8P*8w&XuEd#Si}>;cG+WC$8)`h2jaDfd}ru-MGkaE5N delta 5056 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 O-5ud^r!V1}lKu+?T5ZPw diff --git a/jackify/engine/Wabbajack.VFS.Interfaces.dll b/jackify/engine/Wabbajack.VFS.Interfaces.dll index bc7a10bec3a8a7600aae604823f0054c8449dfb8..df37dc04a0a95338a5474fd61e4f0fdf94c24a30 100644 GIT binary patch delta 326 zcmZqBXwaC@!NNYtxnpC`A7)0D$(<~!qAUzB;Kl@Du`;l1KFlJ^EFgSq^SZ_lyzZT% zl|QpH=4MU)!2ZJAM9+YM!I+Uj&p^*q&qUkA)F?UCEIHBGz|7DnEy>W#GAY@@)HpTO z*vQ1fB+bmk(9$F+*}!ZvKc^y#vybNa$tIiu0*;CI_0LV$_L=u&emeKt`%5NI;Ve^t z3g*BBL8`sf?z}jkcP^-R=8M|RT3jhioGc8iU}v&yp3YOs$Y`>emoJ|&*n~lw!Gyt- z!H6N5A(g=lNG38EGZ+9_hCs12h9n?u#$XAglNl_4>WqP^Qh}rqP?rS|rvYV6fNCs( hBuEWV=j8MJrmQ9mdJG1e1qD7ZPBh@y%qaAq5dZ~pR#X50 delta 326 zcmZqBXwaC@!O|Mwx@Tk0A7)18$(<~!qRb31;Kl@Du`)1kKFlJ^EMPQ&TUyk?*kNYy z)v1ekr9Msm!2ZJASkHig!I+Uj&p^*q&saM(*)q}GG|en2+0-y8$C%jgu`? z(+o^Z5=|0QlT(Z>Ci8PDviKy5Jf3XA86Z&AEOyRZ$IQ8JPt@D35?P#+r*M`jKm|YZ zK?Q-TlOLS=b<9XjYwq#5@b=AGTq#VP%nYnxXEJY|&Qr?BXuO%1FP|~km_eH%l_8nI zk|B}7oWYbKjlql|3CK5PFa**`Kyf25od%Q#@jIC+DaG0*Ut54WZ8UnuD+qbrOk!)jTeQT`rJ+2g&X&1Zss|!C2ww` zXTZQ<%*dc;pl7OQqHSVol$>gooM>!dW@wa_WN2oYlx$&YoSJHEWMW~GW@ch&X_Ay| zV758;9WxtCk|5jR$t7O`1X9f=JEXi5_uk?j$`^9lFm3XcF9`}z!QCZLL6GVX(c%FQ zu3SvueRM2-bI4aKMotz6RAgXkWOZ>0ID+vs!9ctMnGK_K%54YH36!z1d<>%K%JArf0_b~ J@BLZK1OROPWRd^? delta 374 zcmZqp!QAkJc|r$^3-6rm8++c(W@MgRJx3KtE}SDQ%FF-*$ubZYD+BZ7lXKL8@*n32 z14-7o>PV_2q!Fqd=UM~hYv=kiGH*URSKm-zv6hs)QD3X~?$cIFS7nq*Z{|6#C2wx5 zXTZQ<%*dc;pl7OQteu){nP_gBW|ov}YM7K{X_SU5KQo4l|{9mTlG7( z*ptnH{+Bj~e6?caWM*ImJDPd(^lz?=jK-VqephDVU-Os4_mL3|Kx3|5~CWE(J;0AV5+gG@;Q%2`Yf|7i*|zV~M_699W& BX_o*1 diff --git a/jackify/engine/jackify-engine.deps.json b/jackify/engine/jackify-engine.deps.json index 96bfbcd..085336b 100644 --- a/jackify/engine/jackify-engine.deps.json +++ b/jackify/engine/jackify-engine.deps.json @@ -7,7 +7,7 @@ "targets": { ".NETCoreApp,Version=v8.0": {}, ".NETCoreApp,Version=v8.0/linux-x64": { - "jackify-engine/0.5.3": { + "jackify-engine/0.5.4": { "dependencies": { "Markdig": "0.40.0", "Microsoft.Extensions.Configuration.Json": "9.0.1", @@ -22,16 +22,16 @@ "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.3", - "Wabbajack.Downloaders.Bethesda": "0.5.3", - "Wabbajack.Downloaders.Dispatcher": "0.5.3", - "Wabbajack.Hashing.xxHash64": "0.5.3", - "Wabbajack.Networking.Discord": "0.5.3", - "Wabbajack.Networking.GitHub": "0.5.3", - "Wabbajack.Paths.IO": "0.5.3", - "Wabbajack.Server.Lib": "0.5.3", - "Wabbajack.Services.OSIntegrated": "0.5.3", - "Wabbajack.VFS": "0.5.3", + "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.24" }, @@ -1781,7 +1781,7 @@ } } }, - "Wabbajack.CLI.Builder/0.5.3": { + "Wabbajack.CLI.Builder/0.5.4": { "dependencies": { "Microsoft.Extensions.Configuration.Json": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1", @@ -1791,109 +1791,109 @@ "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.3" + "Wabbajack.Paths": "0.5.4" }, "runtime": { "Wabbajack.CLI.Builder.dll": {} } }, - "Wabbajack.Common/0.5.3": { + "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.3", - "Wabbajack.Networking.Http": "0.5.3", - "Wabbajack.Paths.IO": "0.5.3" + "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.3": { + "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.3", - "Wabbajack.Installer": "0.5.3", - "Wabbajack.VFS": "0.5.3", + "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.3": { + "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.3", - "Wabbajack.DTOs": "0.5.3" + "Wabbajack.Common": "0.5.4", + "Wabbajack.DTOs": "0.5.4" }, "runtime": { "Wabbajack.Compression.BSA.dll": {} } }, - "Wabbajack.Compression.Zip/0.5.3": { + "Wabbajack.Compression.Zip/0.5.4": { "dependencies": { - "Wabbajack.IO.Async": "0.5.3" + "Wabbajack.IO.Async": "0.5.4" }, "runtime": { "Wabbajack.Compression.Zip.dll": {} } }, - "Wabbajack.Configuration/0.5.3": { + "Wabbajack.Configuration/0.5.4": { "runtime": { "Wabbajack.Configuration.dll": {} } }, - "Wabbajack.Downloaders.Bethesda/0.5.3": { + "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.3", - "Wabbajack.Downloaders.Interfaces": "0.5.3", - "Wabbajack.Networking.BethesdaNet": "0.5.3" + "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.3": { + "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.3", - "Wabbajack.Downloaders.GameFile": "0.5.3", - "Wabbajack.Downloaders.GoogleDrive": "0.5.3", - "Wabbajack.Downloaders.Http": "0.5.3", - "Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.5.3", - "Wabbajack.Downloaders.Interfaces": "0.5.3", - "Wabbajack.Downloaders.Manual": "0.5.3", - "Wabbajack.Downloaders.MediaFire": "0.5.3", - "Wabbajack.Downloaders.Mega": "0.5.3", - "Wabbajack.Downloaders.ModDB": "0.5.3", - "Wabbajack.Downloaders.Nexus": "0.5.3", - "Wabbajack.Downloaders.VerificationCache": "0.5.3", - "Wabbajack.Downloaders.WabbajackCDN": "0.5.3", - "Wabbajack.Networking.WabbajackClientApi": "0.5.3" + "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.3": { + "Wabbajack.Downloaders.GameFile/0.5.4": { "dependencies": { "GameFinder.StoreHandlers.EADesktop": "4.5.0", "GameFinder.StoreHandlers.EGS": "4.5.0", @@ -1903,361 +1903,361 @@ "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Downloaders.Interfaces": "0.5.3", - "Wabbajack.VFS": "0.5.3" + "Wabbajack.Downloaders.Interfaces": "0.5.4", + "Wabbajack.VFS": "0.5.4" }, "runtime": { "Wabbajack.Downloaders.GameFile.dll": {} } }, - "Wabbajack.Downloaders.GoogleDrive/0.5.3": { + "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.3", - "Wabbajack.DTOs": "0.5.3", - "Wabbajack.Downloaders.Interfaces": "0.5.3", - "Wabbajack.Networking.Http": "0.5.3", - "Wabbajack.Networking.Http.Interfaces": "0.5.3" + "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.3": { + "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.3", - "Wabbajack.DTOs": "0.5.3", - "Wabbajack.Downloaders.Interfaces": "0.5.3", - "Wabbajack.Networking.BethesdaNet": "0.5.3", - "Wabbajack.Networking.Http.Interfaces": "0.5.3", - "Wabbajack.Paths.IO": "0.5.3" + "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.3": { + "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.3", - "Wabbajack.DTOs": "0.5.3", - "Wabbajack.Paths.IO": "0.5.3" + "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.3": { + "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.3", - "Wabbajack.Downloaders.Interfaces": "0.5.3", - "Wabbajack.Networking.Http": "0.5.3", - "Wabbajack.Networking.Http.Interfaces": "0.5.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.IPS4OAuth2Downloader.dll": {} } }, - "Wabbajack.Downloaders.Manual/0.5.3": { + "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.3", - "Wabbajack.Downloaders.Interfaces": "0.5.3" + "Wabbajack.Common": "0.5.4", + "Wabbajack.Downloaders.Interfaces": "0.5.4" }, "runtime": { "Wabbajack.Downloaders.Manual.dll": {} } }, - "Wabbajack.Downloaders.MediaFire/0.5.3": { + "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.3", - "Wabbajack.Downloaders.Interfaces": "0.5.3", - "Wabbajack.Networking.Http.Interfaces": "0.5.3" + "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.3": { + "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.3", - "Wabbajack.Downloaders.Interfaces": "0.5.3", - "Wabbajack.Paths.IO": "0.5.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.3": { + "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.3", - "Wabbajack.Downloaders.Interfaces": "0.5.3", - "Wabbajack.Networking.Http": "0.5.3", - "Wabbajack.Networking.Http.Interfaces": "0.5.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.3": { + "Wabbajack.Downloaders.Nexus/0.5.4": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.5.3", - "Wabbajack.Downloaders.Interfaces": "0.5.3", - "Wabbajack.Hashing.xxHash64": "0.5.3", - "Wabbajack.Networking.Http": "0.5.3", - "Wabbajack.Networking.Http.Interfaces": "0.5.3", - "Wabbajack.Networking.NexusApi": "0.5.3", - "Wabbajack.Paths": "0.5.3" + "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.3": { + "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.3", - "Wabbajack.Paths.IO": "0.5.3" + "Wabbajack.DTOs": "0.5.4", + "Wabbajack.Paths.IO": "0.5.4" }, "runtime": { "Wabbajack.Downloaders.VerificationCache.dll": {} } }, - "Wabbajack.Downloaders.WabbajackCDN/0.5.3": { + "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.3", - "Wabbajack.Downloaders.Interfaces": "0.5.3", - "Wabbajack.Networking.Http": "0.5.3", - "Wabbajack.RateLimiter": "0.5.3" + "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.3": { + "Wabbajack.DTOs/0.5.4": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.Hashing.xxHash64": "0.5.3", - "Wabbajack.Paths": "0.5.3" + "Wabbajack.Hashing.xxHash64": "0.5.4", + "Wabbajack.Paths": "0.5.4" }, "runtime": { "Wabbajack.DTOs.dll": {} } }, - "Wabbajack.FileExtractor/0.5.3": { + "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.3", - "Wabbajack.Compression.BSA": "0.5.3", - "Wabbajack.Hashing.PHash": "0.5.3", - "Wabbajack.Paths": "0.5.3" + "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.3": { + "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.3", - "Wabbajack.DTOs": "0.5.3", - "Wabbajack.Paths": "0.5.3", - "Wabbajack.Paths.IO": "0.5.3" + "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.3": { + "Wabbajack.Hashing.xxHash64/0.5.4": { "dependencies": { - "Wabbajack.Paths": "0.5.3", - "Wabbajack.RateLimiter": "0.5.3" + "Wabbajack.Paths": "0.5.4", + "Wabbajack.RateLimiter": "0.5.4" }, "runtime": { "Wabbajack.Hashing.xxHash64.dll": {} } }, - "Wabbajack.Installer/0.5.3": { + "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.3", - "Wabbajack.Downloaders.Dispatcher": "0.5.3", - "Wabbajack.Downloaders.GameFile": "0.5.3", - "Wabbajack.FileExtractor": "0.5.3", - "Wabbajack.Networking.NexusApi": "0.5.3", - "Wabbajack.Networking.WabbajackClientApi": "0.5.3", - "Wabbajack.Paths": "0.5.3", - "Wabbajack.Paths.IO": "0.5.3", - "Wabbajack.VFS": "0.5.3", + "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.3": { + "Wabbajack.IO.Async/0.5.4": { "runtime": { "Wabbajack.IO.Async.dll": {} } }, - "Wabbajack.Networking.BethesdaNet/0.5.3": { + "Wabbajack.Networking.BethesdaNet/0.5.4": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.5.3", - "Wabbajack.Networking.Http": "0.5.3", - "Wabbajack.Networking.Http.Interfaces": "0.5.3" + "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.3": { + "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.3" + "Wabbajack.Networking.Http.Interfaces": "0.5.4" }, "runtime": { "Wabbajack.Networking.Discord.dll": {} } }, - "Wabbajack.Networking.GitHub/0.5.3": { + "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.3", - "Wabbajack.Networking.Http.Interfaces": "0.5.3" + "Wabbajack.DTOs": "0.5.4", + "Wabbajack.Networking.Http.Interfaces": "0.5.4" }, "runtime": { "Wabbajack.Networking.GitHub.dll": {} } }, - "Wabbajack.Networking.Http/0.5.3": { + "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.3", - "Wabbajack.Downloaders.Interfaces": "0.5.3", - "Wabbajack.Hashing.xxHash64": "0.5.3", - "Wabbajack.Networking.Http.Interfaces": "0.5.3", - "Wabbajack.Paths": "0.5.3", - "Wabbajack.Paths.IO": "0.5.3" + "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.3": { + "Wabbajack.Networking.Http.Interfaces/0.5.4": { "dependencies": { - "Wabbajack.Hashing.xxHash64": "0.5.3" + "Wabbajack.Hashing.xxHash64": "0.5.4" }, "runtime": { "Wabbajack.Networking.Http.Interfaces.dll": {} } }, - "Wabbajack.Networking.NexusApi/0.5.3": { + "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.3", - "Wabbajack.Networking.Http": "0.5.3", - "Wabbajack.Networking.Http.Interfaces": "0.5.3", - "Wabbajack.Networking.WabbajackClientApi": "0.5.3" + "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.3": { + "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.3", - "Wabbajack.DTOs": "0.5.3", - "Wabbajack.Paths.IO": "0.5.3", - "Wabbajack.VFS.Interfaces": "0.5.3", + "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.3": { + "Wabbajack.Paths/0.5.4": { "runtime": { "Wabbajack.Paths.dll": {} } }, - "Wabbajack.Paths.IO/0.5.3": { + "Wabbajack.Paths.IO/0.5.4": { "dependencies": { - "Wabbajack.Paths": "0.5.3", + "Wabbajack.Paths": "0.5.4", "shortid": "4.0.0" }, "runtime": { "Wabbajack.Paths.IO.dll": {} } }, - "Wabbajack.RateLimiter/0.5.3": { + "Wabbajack.RateLimiter/0.5.4": { "runtime": { "Wabbajack.RateLimiter.dll": {} } }, - "Wabbajack.Server.Lib/0.5.3": { + "Wabbajack.Server.Lib/0.5.4": { "dependencies": { "FluentFTP": "52.0.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", @@ -2265,58 +2265,58 @@ "Nettle": "3.0.0", "Newtonsoft.Json": "13.0.3", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Common": "0.5.3", - "Wabbajack.Networking.Http.Interfaces": "0.5.3", - "Wabbajack.Services.OSIntegrated": "0.5.3" + "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.3": { + "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.3", - "Wabbajack.Downloaders.Dispatcher": "0.5.3", - "Wabbajack.Installer": "0.5.3", - "Wabbajack.Networking.BethesdaNet": "0.5.3", - "Wabbajack.Networking.Discord": "0.5.3", - "Wabbajack.VFS": "0.5.3" + "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.3": { + "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.3", - "Wabbajack.FileExtractor": "0.5.3", - "Wabbajack.Hashing.PHash": "0.5.3", - "Wabbajack.Hashing.xxHash64": "0.5.3", - "Wabbajack.Paths": "0.5.3", - "Wabbajack.Paths.IO": "0.5.3", - "Wabbajack.VFS.Interfaces": "0.5.3" + "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.3": { + "Wabbajack.VFS.Interfaces/0.5.4": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.5.3", - "Wabbajack.Hashing.xxHash64": "0.5.3", - "Wabbajack.Paths": "0.5.3" + "Wabbajack.DTOs": "0.5.4", + "Wabbajack.Hashing.xxHash64": "0.5.4", + "Wabbajack.Paths": "0.5.4" }, "runtime": { "Wabbajack.VFS.Interfaces.dll": {} @@ -2333,7 +2333,7 @@ } }, "libraries": { - "jackify-engine/0.5.3": { + "jackify-engine/0.5.4": { "type": "project", "serviceable": false, "sha512": "" @@ -3022,202 +3022,202 @@ "path": "yamldotnet/16.3.0", "hashPath": "yamldotnet.16.3.0.nupkg.sha512" }, - "Wabbajack.CLI.Builder/0.5.3": { + "Wabbajack.CLI.Builder/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Common/0.5.3": { + "Wabbajack.Common/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Compiler/0.5.3": { + "Wabbajack.Compiler/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Compression.BSA/0.5.3": { + "Wabbajack.Compression.BSA/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Compression.Zip/0.5.3": { + "Wabbajack.Compression.Zip/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Configuration/0.5.3": { + "Wabbajack.Configuration/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Bethesda/0.5.3": { + "Wabbajack.Downloaders.Bethesda/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Dispatcher/0.5.3": { + "Wabbajack.Downloaders.Dispatcher/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.GameFile/0.5.3": { + "Wabbajack.Downloaders.GameFile/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.GoogleDrive/0.5.3": { + "Wabbajack.Downloaders.GoogleDrive/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Http/0.5.3": { + "Wabbajack.Downloaders.Http/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Interfaces/0.5.3": { + "Wabbajack.Downloaders.Interfaces/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.5.3": { + "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Manual/0.5.3": { + "Wabbajack.Downloaders.Manual/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.MediaFire/0.5.3": { + "Wabbajack.Downloaders.MediaFire/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Mega/0.5.3": { + "Wabbajack.Downloaders.Mega/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.ModDB/0.5.3": { + "Wabbajack.Downloaders.ModDB/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Nexus/0.5.3": { + "Wabbajack.Downloaders.Nexus/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.VerificationCache/0.5.3": { + "Wabbajack.Downloaders.VerificationCache/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.WabbajackCDN/0.5.3": { + "Wabbajack.Downloaders.WabbajackCDN/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.DTOs/0.5.3": { + "Wabbajack.DTOs/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.FileExtractor/0.5.3": { + "Wabbajack.FileExtractor/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Hashing.PHash/0.5.3": { + "Wabbajack.Hashing.PHash/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Hashing.xxHash64/0.5.3": { + "Wabbajack.Hashing.xxHash64/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Installer/0.5.3": { + "Wabbajack.Installer/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.IO.Async/0.5.3": { + "Wabbajack.IO.Async/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.BethesdaNet/0.5.3": { + "Wabbajack.Networking.BethesdaNet/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.Discord/0.5.3": { + "Wabbajack.Networking.Discord/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.GitHub/0.5.3": { + "Wabbajack.Networking.GitHub/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.Http/0.5.3": { + "Wabbajack.Networking.Http/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.Http.Interfaces/0.5.3": { + "Wabbajack.Networking.Http.Interfaces/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.NexusApi/0.5.3": { + "Wabbajack.Networking.NexusApi/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.WabbajackClientApi/0.5.3": { + "Wabbajack.Networking.WabbajackClientApi/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Paths/0.5.3": { + "Wabbajack.Paths/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Paths.IO/0.5.3": { + "Wabbajack.Paths.IO/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.RateLimiter/0.5.3": { + "Wabbajack.RateLimiter/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Server.Lib/0.5.3": { + "Wabbajack.Server.Lib/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Services.OSIntegrated/0.5.3": { + "Wabbajack.Services.OSIntegrated/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.VFS/0.5.3": { + "Wabbajack.VFS/0.5.4": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.VFS.Interfaces/0.5.3": { + "Wabbajack.VFS.Interfaces/0.5.4": { "type": "project", "serviceable": false, "sha512": "" diff --git a/jackify/engine/jackify-engine.dll b/jackify/engine/jackify-engine.dll index 9fec9dd499e82ababe01a5141b2019867cc49400..070700eff61eef278d523a5a8dc9f9291abce71d 100644 GIT binary patch literal 228864 zcmc${37A|()i-|UcK7X>o|z;)Jv~bnG6W`EW=RNyKo&ySA%uP31B5L=F6kz0w`&*> z5FsEU0~#OC|`PSw4)I+-l~zxV%q^E^|h>eQ*K zQ&p!>A2&CVdAM@{_xDCfHkFRuSk-GUd_-}m?v z%3Y_Fg5yd@p4@fxkxP~Yr*<86Ojl{ylCBe$bZx)KK3yjVi;kJn+??CcWWDDOj>PnoYTOW7hwUr#6Pc&fPPo=%^>-gzsHdVc>blFh3CgLnI|YX zW3;C$3dfiPLElX&=e0~N$__n#MatRNM*O$_F2~u~N;BouW6n4g;qNX3|A>>in|efK zKrft9DwmE1LU;p@(mrPZT7UE6N?XX)c*%q7WSpxnsdFy;w9aAMZh>68E=)N$HLWJm z2&?Cj7;om6;rbJqKzQ-Q4Z48nJKbMj@GQj>4ueU=AK`Bx zcoyr0bOT6cu5Ty5TkvGV3+V=viJ$6kBzUsrg>(a-_;!C|!IModq#N|W>1pd}?-8Ca z18@B6djEx!3SArTx8H3CTdW4XAU@Eb6HbFZ3!N|;^jqkJ&tQs$PS^~lTIht!0OKon zU{7I_tdmAFLe%oDC3sF5f5~VxZ+Dw+#9WKhy|f<9yqr#ddU9)TW3`R`U>Y)5(cP{) zys@ROduapd=tOk%8@dixnzyON?OvKA9i51denZ#kN^>{2q`H?jl8#P9N57#P>q_$% z(kkEgz`Ff2kv5*mDNpfFRmLzkLCCX^lxd?eek3tR}fgE(ka=={IMGjcZ zy2t^`U7H-ly)gsC>=h^_fm<3llkoDmS4f6lAr*RsMARZeBz?0Kb%Q!b-$1&hZx(qN z{km_M*bC|EzS+t24RiCPWZgGYP2VtkFQlurMZ3`!EY1t*ing%(>?n2^{<=0kBfD2k zpGmR3NK-rhS@sCyUka5zGnwe|FJ;R3@1cxjo%>N1rcU%_OPzDd*tjoQ+Pd^5i(i+% zBtL8G%WjNmGPt%e9ryVx5VOx`8#u?ntqh!N;MRo6Ou}T40j7csFcD>di8zxm5oZ!f zBr?HyBr7rjn_%R5gjsqaT`d{WJi; zIkLJgGDqImMdnzUwaHvJ<`LFzo#qkNcircad7!2gZ)4!L25x8I_6F`?;Esf!#2Sss zAoYH5*ArKZ_UT6HkpnNJt3~?=qx8s;7t+~eT~O|UdM@p+a~xPd(7)5LxS0^^Uvj)IV}Vszm$AS)t;<+oCD&yvu-5A|7T6AJ z9Shy#b-xz87O#700n)gfPGo%1ZyR3+m**o_ANEbIya0~tV|wvmqqV~ zQhGNO|G+}zGjJpvlRBeI$Y+A1fOP$%;ZAnj{6zvi{}@83FIlFs{py@n^g$Ea&-G#H zdBL%ufGGRN0iusm&MO%Z;BVkWbjHBPaJu{3ClCA?o~k#a&hg5}gS2~)F}{l~O8F_c zlLt;E{a-R!cuCp=XQ_VFW$I3G|PV{_nN)vlDv8_-HuLHw!)VTYBIhwe)imddB#pgnqQp zL*1kY{->6HtAw60{urT$!UsPXHR*wW*3!>S=o#a;2t7t1=$|M3ziR2XPUsoqw+ekx z=-(mzr?vF+5_-n?Z9?BJ^pLIez`twhw@K(3<97&sr_i5A`p;_Vw@v67HkwpzgI9L$ODHO%!^J`t-o(we&kA^o;SlgnoUY$CyqJ zICVAhzhgqr7=Mz`Zy@xCk-n~$ey4<VLeCh#Pv|lGLf*C~y;n=W zAfacBk2<3LG5>;oU(#o5>32)$8RKJ&fPR|Lqixdz^|kc7C-jWrS_AZ(3H@HAZ>Xi; zBcW#uRUYU!7kZc~>497={hkRuWBeIHzlG4BPWr}L`n?i*#&D)X{xgMs8`3w`((j$n zGsd4K^s|K?Qjs2LuBG26p=XRgN9fU0XwM&!epD^}z6m{J{JBEEHRi;kO$7wm~9;>xp#W=#-HBl9O3jl0iz zHz#=* z7s622+|a)Xyy)#Z8VLRU2%yvJ3YmPSzqfaKHh?hZ3SMu2eQ<{MZO{;$sePR=A3iTpcL=@2Z20JRG3qL8JZcv^+4R<#chWCyPS& zgyTcY_3AP?K27@pDSt5o(9>i9ikc${S~$&R9`rC^Rv{H&MnTU4dJ0+;uwFrrYJNH1 zFo{!+h2KLBzlRA-slx@P)e!*6AiV9jo@$ zGmLwI&O2~M=qu@p&q&Zx9}(zg;WXAWD{cwsdR?cTP%g}kxK4|NS63Y?Fr|(Ym{!LK zEUKdgRy3Ip<;?NU&`5QGp?6AYOE@FcC6SsHF{_ZfXJf&9nu(vI^J+|>>w@WrU5qi3 zarOg*p#(d5J%|vFI3U^VV@yu$W6U||L)k%2yQQ`U=ah+K%IAS{{m=E^*)2}BI00)T zuY5jb5R;Aw0j3mFhU>76+N?IhjtlgZt3rV(r39u`MPN~#DX^l+xZ-9TF=Vw7l!pA3 zHUUqi`Ec4-?eC834gJJQLpMpKQAS(`%5J+=hl_+qS1lKq zQWpzMt4joC)RzSo)t3ZTG`SCBnss5ws!J@pFR?CFIB|a`-FZ$9ap%*y7S8HqSJX&d zV?>}UyA|t^%!imQSy{{~tn55pNzRAR^iGFW$2){~SKTWxrS1`!R(A@_sJjG0I_}n< zjQY0rcOTf501&9(;}G9xZUX-nh=b&2(DwCINwMJ#=ZQjSg+(pfk2$~;Iju6 ztQUK_!h}OPgB^lV6xmzjEctp&y319M3rwje1g6!K0yF9SL$T~w zbM@Y2?<6?!T#dnP=4zRW?yp|AjSe5^Xi9e?ESxle@87516g1{^l z>keozXS`MKXQV!^dR|~kJu5J+o)cJ9KN48cWc~tOUxuvu#yVsEXfSguoLFZhwu#Jl zg)tzQ2q%`Oq%XH)+)i}%iV1XexNNZIJ5(W=@1XAE`R-@JtE+x4Fr{7-m{zX~EUH%p zRx}x>efAwgBh}@6a`H7eYphF0-l|J7Hj?>n&zL~h1zOjZODG@8CDel@mze$HI=v-4 zyJ}cqO8r`3S`7)zsJ8_c)vp9rG`YXKt{X!m)$Il_Rk|0>8tc~5V%06_$V4uu#00u- z7!B#3`{UUg!os?tovGWwl1yZnykt_|6A;RbW05im8$Yh|AEhp?`jfzv`m?~a`dDC5 zeI&4=$yh)3BSUdr^_r(k=XnX#rg~4(xa#);Q|f(zMfE#@6&>w^DdxqH|7Fp(1Xh_H|g?MN$_smEH$>bwA(*OP+y1|R<|3M(UxhwH(MyJ_K^l+`(So-*0>n&LONvvKBa$!ANE{wCXfncy-n0 z0#j;+z@pkrU`0p!=w8-=A*&9t%v^)QOW%hR%S_TA$$U1cEvkcN5lvvLg~JgTO~I-8x6J z!k@&_x`^$7B^p6S9ZP@;9WOAejuV(sCkXV^VnA)rp#7Z5R$Cn-Rd>~K0#oXEfoXN3 zz>Hcf5Lq9qJ;+l$MYTv^MQ7k+FtO1XG7^!DnT>RLVju4(sa$n{z?9lwU|Jm{Fry9@ zSXBE8tY~r{W0Scvq|IWC#|Gy{=@InFQnu(haK=XVX(DeAFz#VGFT#oKiKNq#MaqW> z!N`3wt|QK&^W~(7&44a#L1)*PKzG`SZ1=fts@1;?vxMt^#mp711Jg_X;toBDU6ocR z6J!+j%%npDfmwBmz>Hcd&{HLW^$N=$J&rjBe5+s2mG*Phc>+`FivrW?e1RGD1%c?- z3&c}Y=LoFmbUw0UgEOSNbKEA_=c#j?jZsD!6N#7eQo-e@&cG?4k#nd3o~nn*#Vdam z5#57~@u5h2RT(}nt?8H6(qrC<=ozEwOYpTddZDBXPn8sHlRdBMWk~)tX5XxcZ}6d^ zdxNWhY3kjzKDZhlp0D~}rw4b^{A=JDxE79o9i0A++>lLOIsskPZjOx&`SR%)s4M*I zLDt?_AKXCldxmrMeO`S4SFf`RLEF0+_M$rRNAnVNA@!e(u`|F1_bSd3m{Mm7#9oEKj8X!N>U4n> zP3~icV3#vwjj>qoWjZ0>+rf$TUNQiZA@mdo;xU#Gj5I>JR~cjfVi65vqa5@QI+YTh z>?bax;HTATk=(3)ztzs>N))!x^FOiG6Wj%z4e6oUC5|nh@CQ-($uh_Y>%x4#!@cp3Yh(n;>WCx}QmqQD-qy z>=N2keO4RXB6V}stpZc(Hi1R8LSRKl`-?KAL`u7kM-^^{Pexnc)X{a-Hp+oqL-1E10!Qz+0Ce|{nd}9(iqD%| z+7Ia(hiAJfAA1~LY-|qIv6KozzqKO+RK>+ zJyl2ku$SXtA2eV721}~pCio^%rH4^Ka67&2!5tdhNzfMDr9*cE`1inRs_)ynzU!ez z#|iF5aB_X{Er9;s`rzAu!F_a~pmE1QHt73X8iM-~oVaLeeM2EV#Z_7cuW-EE~8!47Iz`klaz(%%Jkma-FoFJ#(qw6HLr$$QrnvMo-e!1CD@{&$g0 zp*~;lKLAhPjFx)4;U5G}cq=sI4R@`1!``M-fdhXRqUrKp`++=-$Y(A+VbiN?nydUh z(-`i0(ng_(z};!4nAQwj7IeUhpLRs0k&%rOtl}q2Osa zGk<{+YSBLex(PJTkOtkVC#2`-!Gu)rKMx;dg#FZixM`b*t+r{1#N3p#90T9XONM&% zdPz%01-*dmI5c!(|HlaS9_=VyrMi-A)N|^Unba&LG?ly^DL83^~Y7$8y zo=lXpepUVa^WmJ==XrXrZ1WeQ1$(v`ulo?~?)0G@55a#9e*P^ky^owr|AAvE9B5rz z;hc>i6#gKwNW=N=CcsV5kI|XTL+CHgcRv)EQV$DEtM3cUs2>P~t@DWXWYja-=gQ6rXN-c{jbn}z4pIK|Bc%JGx~4V{)_2{9wj}-h29nB zNFsto(Fm%xQF27}QrUUkUO*|Knj_h3^xviZKc^o`j`-K<|CaVcx<#`Q{|5cgR`mZ8 z{%*NvmeKh`nu&jtG|&|E|B8O72m0Tl{|DMXL_dbR*Ih=H7%MdxCK~+3h+h-&6ASS+ z5op4g;x|N~>0-n?M4-*Q;4xy$e~>ut(Rjgo+J71Szo&yS>@~WwqW_QD50DG~to?tY|1a7PR4(|d_J2(O z-{B9Sz`S(d4r#ln6qDroHpw;&d>axjn)6P0s$dnG^AkjN4>HC-3gdv+-Fm6}yew^$ z|958?ch#X|@mIQYB2>*Ya3(fIi$t(zHrbPfyLCokA54yTMqwLqiIr!aQP_tV$yV<_ z!Gy7q$6DL;v%0{bID5lq2<$_Qi%1~Oy$Qj1D~I&@AP{E~d?tqp7QwM35a+^#U_FcA z1rmt!T0$_f!J_Dx1PXB$z-P*sn6oHuAcZ*3Srm=dam}O+Kq1bi@aa6t=R$x6&!Lu2 z3|`=<`_Cu9l9iFv|2v^SH?{QtPat*F|1Szmsh0$%)lUT$)sF>MG#M{o((YwQt7+Lw zI83Aja&i%w<6dIC|CWf!O^myh&bMR6YixZvYI%=0&zwmu&7=USkckEM9R7_WYuL(RF~-+f|CVsM%af-)-6|n!0rSX3~Ao0 zhf+@WOv3THc!0PlLWl>5iz9@1fLI*KtF4Qc!2my zgb)u9mq!Tk0P)obAs!&Eh!Eld;%gB?JV0C-A;bg3RS`lwKwKRm!~+CoYR#BxFJG_2MEmdI#clgaZ`j459J@NEq|_D5|ZvQUUrP} zvP)e5SO|z>XBaHI!FbsL#>=KRUN*Y%vbo*z-!Lu}>*LB~tymLR=2bFB2v_eFGukNjxav zn-M}hK-?Z7!~?_~5kfpb+!-On1H@esLOej+9U;U6#61y0JSz-qEYf(HxA8KQgyDOm zl;S~F--;08In5+utnm_`r2lqAE*_-6FG7e1i2Eaic!2m$gb)u9-;EIB0pfuOA)e^U zIwBs7BE$m(3_so8;sN5J2q7LIU=C^u@vLx5_e1+Arg#0nQ`DH!r8$#RBr#P>G$)ps zoD+Tk(1TIH@vsHpbges=Fx~s2BXNdjbmn_nXKFOB(^F?Mr0*C?9y=kA%`N@=7n8>) z0#oX*0@Ld60*mTnffY^WAFuOd$kM~{obr9@;WyyKbIPW%>5=H+O}ZmJj5+03F~Oxk zmoBHXA|}vt$|G!p;kJn+Jn{ukU93Au*>v9;<j%%)b;ou%4?nQ=PFlVN~HuA6*e`{ZxtQw zpQQO_$p06TS$5Jhx~;$m?WE^e6y<>B%(B|6Ao;nfC@`g31r}9{z>1FM9bUE%Lst9D zL0jIn9FC^+SM!f-Vq(9hB zv#oY|P};*)-xHWp4+$))2Lx7hG_N(YofwMSN#D;#TX9VMhvnQTXBwL)N8 z-7GMpZV^~iHwdg~azD^{Gvt4cY_yF?`QJ+Cjg79HdPdT?>N$Za^}N8edRAaYy&w>G za|Bj2IdvZSX2{2v3|8KAb>1oFyJot|^%a3>b*Vty6%<%h7YnRta(}7Ln;}0_ zlQ-I!Z6bUJlEzg_1*TLM(&tg|G6cUo^RYn$DXczp*Co?XA3zlr*k7N?=MIEf5FM0yAonz@l0x zu%gNR({tg>v2b#X>mAF-d&rfpx8R!xJOgMwEG?HsD z8TD%fvE~~hNUOI9poHHh5$6=j@eW!ZcB6j#K>FEL9|=sUKM72$4+Un_9|acG9|Tr3 zxqp_H2Zk)&c_8xMQRkfpkLoi??N=DngBzPev(DVE-8!EBMeiOAD z=NRa!*jCC5%dTn>m{J8(g)ssvI@+)3DlinclXW+%V=6f4gEPWF5mRJ<1k2&r1LYm^ zlo(p?B8Z;;EwQwxGrVKRx0CX2NF+W%jW+*rSk;P#)Mrh4PNM$ZMb}Godz??(o$Et8x_~bQ*|kx$RSn^rOIun#93a?lyWZ;4gQWbk5)C@ ztK1*??uvZVK|2VSZH%&Uu(65fcOcjucEW88aVV5)%69kT?D8Fy(_OFcc*qqGe;guv z1Yi0LkveL;MlVT~rf&kq#)GD>DOEZ=3gC-;wtw1r80GUXU7i3M-tz99oc0Gb=mIM3 zpQ8ikYVZXO&J$2x9~9mGB)Gu_Ob?Tb=`YaWivk9IkL+a+m$k2Y_3fzNp}@g1{M#+PPZlaJoX4ZH`m#VF2@yfPdx%9x-Z-g-CuBmAX{*piok zkG5*Mty-s8l8KTQPojL(7f&KcDrtW!3T2z9f%j|6Mp<4NAH$(+#`u?_C>|~JHp@PX zzZrR{dZ6&i)8Y3whcobp+D!2AoL5FAkqKjhE#M8dq=PS}bG#?i+YroV_*h7>TxFSq zQ2$)Ftve^5;k)^?`V*46`fGgH&tE~J;eFkZ*aGl`)nW&}aB+(!5w5r#hx{6cI|~_i zVpV4(8RRqmTof?eT8qy5TLUZSca+5}{*Ol2EXIE){2UZBGzTev9x11kQC)BF&nP?Y z@A;`y*TeZ=)jI?IUB<_UxgMaPUx32c_saNeQBcza)xx&f&Y-#lbtS2`H>f^A!D#i$ zI~Y{IpkQlyPP9iEQah#w6qINS>;{^r#*DLRC zP@@I46H$8@6qF-aU5~6rsCGdeWmB~XYEPo}GznV;br4Z|85DXLd7VJi-UihyC@hn_ z@;(Ly0Rz=BL`9ue6x5+a;e%J{+AcvsQ+eh645}cgeTdrMphgJ_mS)nAQw7z?3Q0>$ zA+a}z{z(K(_HIxC2OXJD1qUEE%vU8EVm`ha@X{(gF}%9SLJYB&nq7WzkfOOgYcJf<{{l4Om`O3rQ{RYMlZW; zM--_r0pNBU-F~;Nr#81R&JA+ji+0sKK$7z1is1n>_ewmdIF-c%Mz*sT(+!w(m{5=o zlV%!hpS^ODxW0Qc&7etx*#~JbiN^IkXXS+Ck!3;9^5t_)I@k$GSzlN=3ChYiP?UUo z67~<~U(87`Dw;Yye<7L@R>d`(WsVeo%HJ9S>mOwT=(Z?Oy4R+f$A~M?Y)JmyBqCUZ zgp+AUW#l`ymCDQ~Wj>T7swuPdvK{e%Q~n5b9+i~;hF#46TAeY#S8C_=+T&*a*e+7H zaBxi0UeMJmrNzypII%m;>Z6*(Z0r9QWj#VdwjJh&s7-H$SRs*zb( zbdcGJa4|lXQD$j{_+Qh*TE++E9CmBIe40r!Nz%YtWSY}!(?rb7a6@LDs$~IKCSD2q zdMY>;g}UJeh-(XugI^HS#@Z3>NklabgElG&Jh~26GtL3vQEMGhIXO>j8tRPr(!lOT znjW3zqS`c3#n5}qdZpe2mWfxwo|XzuMyt6MShlzx?{;~Weg z+z^Hk%Bf9>YgMoc({7uB$(we( zz7|k1qOFYSDW_mE6I;QnpzsAtfrACToK-8q@5B7-#ghw7$OndUdgvN7jmvS7Q_~S{ z2rwETlk^9W{9FJ@*GBUh@bqsZMl(65(F`ja#_?3REijs|Le@i=0C0Ja5wx2Lt3@n* z>%?wmI@ryaDRbbOWe5|)9bVhig66mnHv)z88)00(<+e*Thnrwy=kazaQVb(|*0=9C z4Fk-hIb7Y$`j|BFPDG36V~!-J?Xc7}r=G%6AmY?>{j168FIn@D5xC4(A*>d6so@mQ zLXgvt#qv3dzV>`Z7O&m?seI-v9}{aX!1M_cb_VZ?wr|#emp>6`9Ns7DD&5iunf_z- z5MuR*rxY5vN)R?jBG9di8>X0xbHf|218vqn7o{mAD+ek_t5T});rEZ>wmZreS}Vm{y#>d-C!6WLA$>U(+STi zBDZWf5o^U#2j^;x>t4A{>W14PYTz<(;(rBB-*`hdFDAPj=wj2*v&=2rejTa54r=t} z4%x5^h3ZoLuOg@53OI%OmU{nd@EDE3I=lILwFjYoG^gJ6ujM%WsuatJLcLodtQH|y zx*wSiIbF#tvu?;*=CcZIl^*UYH0YY-8%nRlb-4lSk=9 zCO;ec(7zhBQ+pcneWIo(qGgnRfg?XExDKsa7~L}3zlN>W+|uk{3lBP?Bd%$)+6!q+ zM>Mpawy5d{n=!fNrbnvQ7puVa=?ZoWx)k46?5;K(A3M-~Ia9g~f;VZ+e~ zCCbWSQ`hU1`m$fvslJ_4{_Fa7uDQNrxnr@;^<^jh7xkU7S#5o_?CRy@^=L#Brl}Y6*VI(YIo_O zsEH0&J=DtVP~3!miLgi4VEvmBrN*rxmqj)7Q7*^SU?msB2Gz0bkwm0e*Nni%aWz=U zpt(D^73}xdHwf&*w}f#&mYO#op?B?8sCro7c;(yJpDW_}%C<+u306230c1#P4~#)l**G69c^tqzZb#YOgN*TS1r4kSlbH=m(_20d#A-)`F#&RVz%j}ltnwTv(cn(_3LPyS{$21u$B&J5UWeKj z#74(=xc+V6Z)h^C2sdOzp~I~ZR*Of~==cu*Zf4o;=G)cYhqy)qbEi%yhc`H^|J_Vb-`IBMO~vh0wC#WajS_ z3$DCEtuj7lWW-h(BaEzTtL%dk9dE*v`6!+K zwY9>8xbP8M;j6iIY=xp}g`(XGJyLzPjx1v}N!2@_0tN+}(`PD2qq%jIERZ9r3s$8n zv~>sfviBPDZQ|)`vxY}$tChxN)jN@Se2XQ06NTgrb=5nm1|Q9wY%f|0gxXSwVkl3H zA&hMZ)(Zv_aR5wYF|($z+5%E*tYSYo8mo-aV>Ppyv0A82dvKDLF?y`-246li^II}j z8JKyijMb$eRy!dajn&K=q;kDjFh-Qhv#xalDBQda&+_f%oi*NKs{R^Kh~DhF5M=2w6eig+b-ZF7jrUV!yGA`tm|;4@StHD zr|h~;S_{QD#>J1=5bt>F*bw+37ioxbc0)`AI~pIdO$J)wSdw}OrHuo(dyp|Qd^I^P zn?!Mf9VphOgdGrlHN+VIb__>f4}5YVyPX4VwP_BEa_k;tOigL!C{CBwUYqjpq%_7P zr4`1<3S|5U3gqNif#mVjx}J?sW?r!hiLgs*u&Y-SQ4D3gR<%H7u;!8GFnYwT>CYA0$VtpZEl1X?6S{)Hj zB@uE%!d{O{0pCU!uQXpm@|xBxdB>;MQSv6_C$#zZp@_nIZH0+#g)TQ_4f5*^bO2c5 zb_5&wei1ju_;*rh?xJ%y9JK*(I;reZ2j2x{$JA(*vUoKyD zu6-VseNZ29oY~iAT*tg~MvOZaaaIazi~Jx9Ej+a!G;LlUm*JUx)jKn)l&;P_tj@h~ z3LE4%2p%9eo3*qTCgwM23m!yBcHN^U62=EQk&o3%3z^~fK-xXX82?+yOj5NUR9HVh zv9FS!knft%p}*s%T>OMB^Dr#O7RUvK+**Bo8;L>k+UiO4mhoIzRQ|vVPF(D{it z1>XmLa(;b$TfV*)y!?cg?rL!c%(HyA{{z9|z`1@1S92^}8>;=mld;A& zbbag(_735}D0C1b3LCl=LTl$%tg#Jc=eDhB2&+h^^cE^GvEl#6O8+xyA*pm*GVm-b zz`tMGQ;)JoSZ92i%^{Ze*4iSY*4!fh60ux+ix|aFO!!;9V!-|WNHSJZ3~f!rI~OK% z7m|$)rA4^`n6JUE8gN|zBxg{!!fIBFo1)WoU*;$4n4B(*?*qP5=kCpSKv7;TP{-04907l3~@c%0AH!Ru@Ry+Qg% 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

c-cFL^uG0JV_W= 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 O7%4`&Lz>I3fBy$Jr(#$D literal 231936 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 f;OMGBi=q=&N{nvW{F;55E2d+_WD$M2+2DTw logging.Logger: + debug_mode = '--debug' in sys.argv or '-d' in sys.argv + if not debug_mode: + try: + from jackify.backend.handlers.config_handler import ConfigHandler + debug_mode = ConfigHandler().get('debug_mode', False) + except Exception: + pass + return LoggingHandler().setup_application_logging(debug_mode) + +root_logger = _setup_cli_logging() +root_logger.info("Jackify %s starting (CLI)", jackify_version) def terminate_children(signum, frame): """Signal handler to terminate child processes on exit""" diff --git a/jackify/frontends/cli/commands/install_modlist.py b/jackify/frontends/cli/commands/install_modlist.py index 03476c9..d7d8a07 100644 --- a/jackify/frontends/cli/commands/install_modlist.py +++ b/jackify/frontends/cli/commands/install_modlist.py @@ -209,7 +209,6 @@ class InstallModlistCommand: 'modlist_value': getattr(args, 'modlist_value', None), 'skip_confirmation': True, 'resolution': getattr(args, 'resolution', None), - 'skip_disk_check': getattr(args, 'skip_disk_check', False), } def _validate_install_context(self, context: dict) -> bool: @@ -317,16 +316,21 @@ class InstallModlistCommand: # Check if game is supported if game_type and not modlist_cli.check_game_support(game_type): - # Show unsupported game warning supported_games = modlist_cli.wabbajack_parser.get_supported_games_display_names() supported_games_str = ", ".join(supported_games) - print(f"\n{COLOR_WARNING}Game Support Notice{COLOR_RESET}") print(f"{COLOR_WARNING}While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to: {supported_games_str}.{COLOR_RESET}") print(f"{COLOR_WARNING}We are working to add more automated support in future releases!{COLOR_RESET}") - - # Ask for confirmation to continue - response = input(f"{COLOR_PROMPT}Click Enter to continue with the modlist installation, or type 'cancel' to abort: {COLOR_RESET}").strip().lower() + response = input(f"{COLOR_PROMPT}Press Enter to continue, or type 'cancel' to abort: {COLOR_RESET}").strip().lower() + if response == 'cancel': + print("[INFO] Modlist installation cancelled by user.") + return 1 + elif game_type in ('skyrimvr', 'fallout4vr'): + game_label = "Skyrim VR" if game_type == 'skyrimvr' else "Fallout 4 VR" + print(f"\n{COLOR_WARNING}VR Platform Notice{COLOR_RESET}") + print(f"{COLOR_WARNING}{game_label} modlist detected. Jackify will handle the install and prefix setup, but running VR modlists on Linux requires a working VR platform (SteamVR, ALVR, WiVRn, etc.) configured independently.{COLOR_RESET}") + print(f"{COLOR_WARNING}VR support is best effort. Full functionality depends on your VR setup.{COLOR_RESET}") + response = input(f"{COLOR_PROMPT}Press Enter to continue, or type 'cancel' to abort: {COLOR_RESET}").strip().lower() if response == 'cancel': print("[INFO] Modlist installation cancelled by user.") return 1 diff --git a/jackify/frontends/cli/main.py b/jackify/frontends/cli/main.py index cf7173e..76bf281 100755 --- a/jackify/frontends/cli/main.py +++ b/jackify/frontends/cli/main.py @@ -411,8 +411,6 @@ class JackifyCLI: parser.add_argument('--restart-steam', action='store_true', help='Restart Steam (native, for GUI integration)') parser.add_argument('--dev', action='store_true', help='Enable development features (show hidden menu items)') parser.add_argument('--update', action='store_true', help='Check for and install updates') - parser.add_argument('--skip-disk-check', action='store_true', help='Skip the pre-flight disk space check (use when retrying after a disk-full warning)') - # Add command-specific arguments self.commands['install_modlist'].add_top_level_args(parser) diff --git a/jackify/frontends/cli/menus/additional_menu.py b/jackify/frontends/cli/menus/additional_menu.py index 4d25b48..b056a08 100644 --- a/jackify/frontends/cli/menus/additional_menu.py +++ b/jackify/frontends/cli/menus/additional_menu.py @@ -39,8 +39,10 @@ class AdditionalMenuHandler: 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_ACTION}→ Apply Wine registry settings for xEdit, Synthesis, Pandora, Nemesis{COLOR_RESET}") print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") - selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip() + selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-5): {COLOR_RESET}").strip() if selection.lower() == 'q': # Allow 'q' to re-display menu continue @@ -52,6 +54,8 @@ class AdditionalMenuHandler: 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 == "0": break else: @@ -150,7 +154,7 @@ class AdditionalMenuHandler: else: output_path = Path(output_path).expanduser() - # Check if output directory already has content — mirror GUI behaviour + # Check if output directory already has content - mirror GUI behaviour if output_path.exists() and output_path.is_dir(): try: has_files = any(output_path.iterdir()) @@ -405,3 +409,68 @@ class AdditionalMenuHandler: if self.logger: self.logger.debug("AdditionalMenuHandler: Executing Setup MO2 command") command.run() + + def _execute_configure_tool_compat(self, cli_instance): + """Apply tool compatibility settings to an existing configured modlist prefix.""" + from jackify.backend.handlers.modlist_handler import ModlistHandler + from jackify.backend.services.tool_config_service import apply_tool_config_for_appid + from jackify.shared.colors import COLOR_ERROR, COLOR_SUCCESS + + self._clear_screen() + print_jackify_banner() + print_section_header("Configure Tool Compatibility") + 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 e: + print(f"{COLOR_ERROR}Failed to discover modlists: {e}{COLOR_RESET}") + input("Press Enter to return to menu...") + return + + if not shortcuts: + 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, s in enumerate(shortcuts, 1): + print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} {s['name']}") + 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] + print(f"\n{COLOR_INFO}Applying tool compatibility settings for: {chosen['name']}{COLOR_RESET}") + print(f"{COLOR_INFO}This may take a few minutes...{COLOR_RESET}\n") + + def _log(msg: str): + print(f"{COLOR_INFO}{msg}{COLOR_RESET}") + + ok = apply_tool_config_for_appid(chosen["appid"], log=_log) + + if ok: + print(f"\n{COLOR_SUCCESS}Tool compatibility configured successfully.{COLOR_RESET}") + else: + print(f"\n{COLOR_ERROR}Tool compatibility configuration failed. Check logs for details.{COLOR_RESET}") + + input("\nPress Enter to return to menu...") diff --git a/jackify/frontends/gui/__main__.py b/jackify/frontends/gui/__main__.py index 334dfe6..f840538 100644 --- a/jackify/frontends/gui/__main__.py +++ b/jackify/frontends/gui/__main__.py @@ -38,7 +38,7 @@ def handle_protocol_url(url: str): if error: error_description = params.get('error_description', ['No description'])[0] - _log_error(f"OAuth error: {error} — {error_description}") + _log_error(f"OAuth error: {error} - {error_description}") return if not code or not state: diff --git a/jackify/frontends/gui/dialogs/about_dialog.py b/jackify/frontends/gui/dialogs/about_dialog.py index cfda211..0fc6418 100644 --- a/jackify/frontends/gui/dialogs/about_dialog.py +++ b/jackify/frontends/gui/dialogs/about_dialog.py @@ -22,6 +22,7 @@ from PySide6.QtGui import QFont, QClipboard from ....backend.services.update_service import UpdateService from ....backend.models.configuration import SystemInfo from .... import __version__ +from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin logger = logging.getLogger(__name__) @@ -45,7 +46,7 @@ class UpdateCheckThread(QThread): self.update_check_finished.emit(None) -class AboutDialog(QDialog): +class AboutDialog(ThreadLifecycleMixin, QDialog): """About dialog showing system info and app details.""" def __init__(self, system_info: SystemInfo, parent=None): @@ -420,8 +421,7 @@ Python: {platform.python_version()}""" def closeEvent(self, event): """Handle dialog close event.""" - if self.update_check_thread and self.update_check_thread.isRunning(): - self.update_check_thread.terminate() - self.update_check_thread.wait() - + self.update_check_thread = self._park_thread( + self.update_check_thread, ["update_available", "no_update", "check_failed"] + ) event.accept() \ No newline at end of file diff --git a/jackify/frontends/gui/dialogs/enb_proton_dialog.py b/jackify/frontends/gui/dialogs/enb_proton_dialog.py index f2b1ddc..60f9db0 100644 --- a/jackify/frontends/gui/dialogs/enb_proton_dialog.py +++ b/jackify/frontends/gui/dialogs/enb_proton_dialog.py @@ -12,7 +12,7 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QWidget, QSpacerItem, QSizePolicy, QFrame, QApplication ) -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QIcon, QFont logger = logging.getLogger(__name__) @@ -145,7 +145,8 @@ class ENBProtonDialog(QDialog): # OK button btn_row = QHBoxLayout() btn_row.addStretch() - self.ok_btn = QPushButton("I Understand") + self.ok_btn = QPushButton("I Understand (3s)") + self.ok_btn.setEnabled(False) self.ok_btn.setStyleSheet( "QPushButton { " " background: #3fb7d6; " @@ -162,9 +163,19 @@ class ENBProtonDialog(QDialog): "QPushButton:pressed { " " background: #2d8fa8; " "}" + "QPushButton:disabled { " + " background: #555; " + " color: #aaa; " + "}" ) self.ok_btn.clicked.connect(self.accept) btn_row.addWidget(self.ok_btn) + + self._protect_countdown = 3 + self._protect_timer = QTimer(self) + self._protect_timer.setInterval(1000) + self._protect_timer.timeout.connect(self._on_protect_tick) + self._protect_timer.start() btn_row.addStretch() layout.addLayout(btn_row) @@ -173,6 +184,15 @@ class ENBProtonDialog(QDialog): logger.info(f"ENBProtonDialog created for modlist: {modlist_name}") + def _on_protect_tick(self): + self._protect_countdown -= 1 + if self._protect_countdown > 0: + self.ok_btn.setText(f"I Understand ({self._protect_countdown}s)") + else: + self._protect_timer.stop() + self.ok_btn.setText("I Understand") + self.ok_btn.setEnabled(True) + def _set_dialog_icon(self): """Set the dialog icon to Wabbajack icon if available""" try: diff --git a/jackify/frontends/gui/dialogs/manual_download_dialog.py b/jackify/frontends/gui/dialogs/manual_download_dialog.py index 382ac8f..397b3d9 100644 --- a/jackify/frontends/gui/dialogs/manual_download_dialog.py +++ b/jackify/frontends/gui/dialogs/manual_download_dialog.py @@ -361,7 +361,8 @@ class ManualDownloadDialog(QDialog): logger.debug("Could not persist manual_download_concurrent_limit", exc_info=True) def _on_pick_folder(self) -> None: - chosen = QFileDialog.getExistingDirectory(self, "Select watch folder", str(self._watch_dir)) + from jackify.frontends.gui.utils import browse_directory + chosen = browse_directory(self, "Select watch folder", str(self._watch_dir)) if chosen: from jackify.backend.services.download_watcher_service import WatcherConfig self._watch_dir = Path(chosen) @@ -441,7 +442,7 @@ class ManualDownloadDialog(QDialog): def _on_all_done_slot(self, completed: int, skipped: int) -> None: from PySide6.QtCore import QTimer self._progress_label.setText( - f"All downloads complete ({completed} accepted, {skipped} deferred) — closing..." + f"All downloads complete ({completed} accepted, {skipped} deferred) - closing..." ) # Raise now while the dialog is still visible so the user sees the completion state self._raise_main_window() diff --git a/jackify/frontends/gui/dialogs/protontricks_error_dialog.py b/jackify/frontends/gui/dialogs/protontricks_error_dialog.py index 1dc7245..58ad285 100644 --- a/jackify/frontends/gui/dialogs/protontricks_error_dialog.py +++ b/jackify/frontends/gui/dialogs/protontricks_error_dialog.py @@ -11,6 +11,7 @@ from PySide6.QtWidgets import ( from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QPixmap, QIcon, QFont from .. import shared_theme +from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin class FlatpakInstallThread(QThread): @@ -26,7 +27,7 @@ class FlatpakInstallThread(QThread): self.finished.emit(success, message) -class ProtontricksErrorDialog(QDialog): +class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog): """ Dialog shown when protontricks is not found Provides options to install via Flatpak or get native installation guidance @@ -322,7 +323,7 @@ class ProtontricksErrorDialog(QDialog): def closeEvent(self, event): """Handle dialog close event""" - if self.install_thread and self.install_thread.isRunning(): - self.install_thread.terminate() - self.install_thread.wait() + self.install_thread = self._park_thread( + self.install_thread, ["install_complete", "install_failed", "progress_update"] + ) event.accept() \ No newline at end of file diff --git a/jackify/frontends/gui/dialogs/settings_dialog.py b/jackify/frontends/gui/dialogs/settings_dialog.py index b070d00..036f86f 100644 --- a/jackify/frontends/gui/dialogs/settings_dialog.py +++ b/jackify/frontends/gui/dialogs/settings_dialog.py @@ -92,9 +92,10 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog self.api_show_btn.setStyleSheet("") def _pick_directory(self, line_edit): - dir_path = QFileDialog.getExistingDirectory(self, "Select Directory", line_edit.text() or os.path.expanduser("~")) + from jackify.frontends.gui.utils import browse_directory + dir_path = browse_directory(self, "Select Directory", line_edit.text()) if dir_path: - line_edit.setText(os.path.realpath(dir_path)) + line_edit.setText(dir_path) def _show_help(self): MessageService.information(self, "Help", "Help/documentation coming soon!", safety_level="low") @@ -125,6 +126,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog api_key = text.strip() self.config_handler.save_api_key(api_key) + def _update_oauth_status(self): from jackify.backend.services.nexus_auth_service import NexusAuthService auth_service = NexusAuthService() @@ -309,6 +311,10 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog self.config_handler.set("game_proton_path", resolved_game_path) self.config_handler.set("game_proton_version", resolved_game_version) + # 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()) + # Save component installation method preference if self.winetricks_radio.isChecked(): method = 'winetricks' diff --git a/jackify/frontends/gui/dialogs/settings_dialog_tabs.py b/jackify/frontends/gui/dialogs/settings_dialog_tabs.py index 37cc25b..2a07ede 100644 --- a/jackify/frontends/gui/dialogs/settings_dialog_tabs.py +++ b/jackify/frontends/gui/dialogs/settings_dialog_tabs.py @@ -170,6 +170,7 @@ class SettingsDialogTabsMixin: advanced_layout.addWidget(auth_group) advanced_layout.addSpacing(12) + self.resource_settings_path = os.path.expanduser("~/.config/jackify/resource_settings.json") self.resource_settings = self._load_json(self.resource_settings_path) self.resource_edits = {} @@ -275,6 +276,27 @@ class SettingsDialogTabsMixin: self.component_method_group.addButton(self.protontricks_radio, 1) component_method_layout.addWidget(self.protontricks_radio) component_layout.addLayout(component_method_layout) + + self.auto_tool_compat_checkbox = QCheckBox("Apply tool compatibility settings during install/configure") + self.auto_tool_compat_checkbox.setChecked(self.config_handler.get('auto_tool_compat', True)) + self.auto_tool_compat_checkbox.setToolTip( + "Automatically apply Wine registry fixes for xEdit, Pandora, and DLL overrides " + "at the end of every install or configure workflow. Disable if you find it adds " + "noticeable delay." + ) + self.auto_tool_compat_checkbox.setStyleSheet("color: #fff;") + component_layout.addWidget(self.auto_tool_compat_checkbox) + + self.force_github_updates_checkbox = QCheckBox("Use GitHub as update source (bypass Nexus CDN)") + self.force_github_updates_checkbox.setChecked(self.config_handler.get('force_github_updates', False)) + self.force_github_updates_checkbox.setToolTip( + "Always download Jackify updates directly from GitHub Releases instead of Nexus CDN. " + "Enable this if self-updates fail or stall. GitHub delivers the AppImage directly; " + "Nexus delivers a .7z archive that Jackify must extract." + ) + self.force_github_updates_checkbox.setStyleSheet("color: #fff;") + component_layout.addWidget(self.force_github_updates_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 c50e1f7..d261340 100644 --- a/jackify/frontends/gui/dialogs/success_dialog.py +++ b/jackify/frontends/gui/dialogs/success_dialog.py @@ -92,6 +92,8 @@ class SuccessDialog(QDialog): suffix_text = "configured successfully!" elif self.workflow_type == "configure_existing": suffix_text = "configuration updated successfully!" + elif self.workflow_type == "tool_config": + suffix_text = "tool compatibility configured successfully!" else: # Fallback for other workflow types message_text = self._build_success_message() @@ -118,18 +120,19 @@ class SuccessDialog(QDialog): # Ensure the label uses full width of the card before wrapping card_layout.addWidget(message_label) - # Time taken - time_label = QLabel(f"Completed in {self.time_taken}") - time_label.setAlignment(Qt.AlignCenter) - time_label.setStyleSheet( - "QLabel { " - " font-size: 12px; " - " color: #b0b0b0; " - " font-style: italic; " - " margin-bottom: 10px; " - "}" - ) - card_layout.addWidget(time_label) + # Time taken (omit label if time is not available) + if self.time_taken: + time_label = QLabel(f"Completed in {self.time_taken}") + time_label.setAlignment(Qt.AlignCenter) + time_label.setStyleSheet( + "QLabel { " + " font-size: 12px; " + " color: #b0b0b0; " + " font-style: italic; " + " margin-bottom: 10px; " + "}" + ) + card_layout.addWidget(time_label) # Next steps guidance next_steps_text = self._build_next_steps() @@ -240,15 +243,37 @@ class SuccessDialog(QDialog): game_display = self.game_name or self.modlist_name base_message = "" - if self.workflow_type == "tuxborn": + if self.workflow_type == "tool_config": + base_message = ( + f"Modding tools for {self.modlist_name} are now configured. " + "xEdit, Synthesis, Pandora, and DLL overrides are ready to use from within Mod Organizer 2." + ) + elif self.workflow_type == "tuxborn": base_message = f"You can now launch Tuxborn from Steam and enjoy your modded {game_display} experience!" elif self.workflow_type == "install" and self.modlist_name == "Wabbajack": base_message = "You can now launch Wabbajack from Steam and install modlists. Once the modlist install is complete, you can run \"Configure New Modlist\" in Jackify to complete the configuration for running the modlist on Linux." else: - base_message = f"You can now launch {self.modlist_name} from Steam and enjoy your modded {game_display} experience!" + try: + from jackify.backend.handlers.config_handler import ConfigHandler + auto_tool_compat = ConfigHandler().get('auto_tool_compat', True) + except Exception: + auto_tool_compat = True + + tool_hint = ( + "

" + "" + "If you use modding tools such as xEdit, Synthesis, or Pandora, " + "run Configure Tool Compatibility from the Additional Tasks menu." + "" + ) if not auto_tool_compat else "" + + base_message = ( + f"You can now launch {self.modlist_name} from Steam and enjoy your modded {game_display} experience!" + f"{tool_hint}" + ) # ENB Proton warning shown in separate dialog - return base_message + return base_message def _update_countdown(self): if self._countdown > 0: diff --git a/jackify/frontends/gui/dialogs/update_dialog.py b/jackify/frontends/gui/dialogs/update_dialog.py index 3606bc1..2ae2d3f 100644 --- a/jackify/frontends/gui/dialogs/update_dialog.py +++ b/jackify/frontends/gui/dialogs/update_dialog.py @@ -17,6 +17,7 @@ from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QPixmap, QFont from ....backend.services.update_service import UpdateService, UpdateInfo +from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin logger = logging.getLogger(__name__) @@ -51,7 +52,7 @@ class UpdateDownloadThread(QThread): self.download_finished.emit(None) -class UpdateDialog(QDialog): +class UpdateDialog(ThreadLifecycleMixin, QDialog): """Dialog for notifying users about updates and handling downloads.""" def __init__(self, update_info: UpdateInfo, update_service: UpdateService, parent=None): @@ -335,9 +336,7 @@ class UpdateDialog(QDialog): def closeEvent(self, event): """Handle dialog close event.""" - if self.download_thread and self.download_thread.isRunning(): - # Cancel download if in progress - self.download_thread.terminate() - self.download_thread.wait() - + self.download_thread = self._park_thread( + self.download_thread, ["progress_updated", "download_finished"] + ) event.accept() \ No newline at end of file diff --git a/jackify/frontends/gui/main.py b/jackify/frontends/gui/main.py index 0fafb67..f26a57f 100644 --- a/jackify/frontends/gui/main.py +++ b/jackify/frontends/gui/main.py @@ -218,7 +218,8 @@ def main(): import signal # Enable faulthandler to both stderr and file try: - log_dir = Path.home() / '.local' / 'share' / 'jackify' / 'logs' + from jackify.shared.paths import get_jackify_logs_dir + log_dir = get_jackify_logs_dir() log_dir.mkdir(parents=True, exist_ok=True) trace_file = open(log_dir / 'segfault_trace.txt', 'w') faulthandler.enable(file=trace_file, all_threads=True) @@ -248,28 +249,30 @@ def main(): config_handler.set('debug_mode', True) import logging - # Initialize file logging on root logger so all modules inherit it + # Initialize root logger: jackify.log (INFO, always) + jackify-debug.log (DEBUG, debug mode only) from jackify.shared.logging import LoggingHandler - logging_handler = LoggingHandler() - # Only rotate log file when debug mode is enabled + root_logger = LoggingHandler().setup_application_logging(debug_mode) + + def _unhandled_exception(exc_type, exc_value, exc_tb): + 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)) + + sys.excepthook = _unhandled_exception + + _mode = 'AppImage' if os.environ.get('APPIMAGE') else 'dev' + root_logger.info("Jackify %s starting (GUI, %s)", jackify_version, _mode) if debug_mode: - logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-debug.log') - root_logger = logging_handler.setup_logger('', 'jackify-debug.log', is_general=True, debug_mode=debug_mode) # Empty name = root logger - - # CRITICAL: Set root logger level BEFORE any child loggers are used - # DEBUG messages from child loggers must propagate - if debug_mode: - root_logger.setLevel(logging.DEBUG) - logging.getLogger().setLevel(logging.DEBUG) # Also set on root via getLogger() for compatibility - root_logger.debug("CLI --debug flag detected, saved debug_mode=True to config") - root_logger.info("Debug mode enabled (from config or CLI)") - else: - root_logger.setLevel(logging.WARNING) - logging.getLogger().setLevel(logging.WARNING) - - # Root logger should not propagate (it's the top level) - # Child loggers will propagate to root logger by default (unless they explicitly set propagate=False) - root_logger.propagate = False + root_logger.debug("Debug mode enabled") + + try: + from jackify.shared.paths import get_jackify_logs_dir + _flatpak = (Path.home() / ".var/app/com.valvesoftware.Steam").exists() + _steam_type = 'Flatpak' if _flatpak else 'native' + root_logger.info("Steam: %s | log dir: %s", _steam_type, get_jackify_logs_dir()) + except Exception: + pass dev_mode = '--dev' in sys.argv @@ -292,7 +295,7 @@ def main(): # Set up signal handlers for graceful shutdown import signal def signal_handler(sig, frame): - print(f"Received signal {sig}, cleaning up...") + logging.getLogger().info("Received signal %s, cleaning up...", sig) emergency_cleanup() app.quit() diff --git a/jackify/frontends/gui/mixins/main_window_dialogs.py b/jackify/frontends/gui/mixins/main_window_dialogs.py index 42e5d43..3f85cfa 100644 --- a/jackify/frontends/gui/mixins/main_window_dialogs.py +++ b/jackify/frontends/gui/mixins/main_window_dialogs.py @@ -3,9 +3,12 @@ Main window dialogs and cleanup mixin. Settings, About, open URL, cleanup_processes, closeEvent. """ +import logging import os import subprocess +logger = logging.getLogger(__name__) + from jackify.frontends.gui.dialogs.settings_dialog import SettingsDialog @@ -21,6 +24,17 @@ class MainWindowDialogsMixin: except RuntimeError: return None + # Disconnect all signals before stopping to prevent callbacks to a dying widget. + try: + thread.finished.disconnect() + except Exception: + pass + for _sig in ("update_available", "no_update", "check_failed", "cache_ready", "progress_update"): + try: + getattr(thread, _sig).disconnect() + except Exception: + pass + try: thread.requestInterruption() except Exception: @@ -37,14 +51,9 @@ class MainWindowDialogsMixin: except Exception: pass - try: - thread.terminate() - except Exception: - pass - try: if not thread.wait(10000): - print(f"WARNING: {thread_name} still running during shutdown") + logger.warning("%s still running during shutdown", thread_name) except Exception: pass return None diff --git a/jackify/frontends/gui/mixins/main_window_ui.py b/jackify/frontends/gui/mixins/main_window_ui.py index 1458414..fc48e40 100644 --- a/jackify/frontends/gui/mixins/main_window_ui.py +++ b/jackify/frontends/gui/mixins/main_window_ui.py @@ -38,8 +38,8 @@ class MainWindowUIMixin: self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode) self.stacked_widget.addWidget(self.main_menu) # index 0 - # Indexes 1-9: insert lightweight placeholders now; real screens on demand. - for _ in range(9): + # Indexes 1-11: insert lightweight placeholders now; real screens on demand. + for _ in range(11): self.stacked_widget.addWidget(_LazyPlaceholder()) # Factory map: index -> callable that creates and caches the real screen. @@ -53,6 +53,8 @@ class MainWindowUIMixin: 7: self._make_wabbajack_installer_screen, 8: self._make_configure_existing_modlist_screen, 9: self._make_install_mo2_screen, + 10: self._make_third_party_tools_screen, + 11: self._make_configure_tool_config_screen, } self.stacked_widget.currentChanged.connect(self._lazy_init_screen) @@ -121,7 +123,7 @@ class MainWindowUIMixin: # Block signals for the entire swap including setCurrentWidget so that: # (a) Qt's auto-current-change on removeWidget doesn't cascade into the # other placeholders via a re-entrant _lazy_init_screen call, and - # (b) setCurrentWidget does not fire a second currentChanged — the outer + # (b) setCurrentWidget does not fire a second currentChanged - the outer # currentChanged (which triggered this lazy init) is still being # dispatched and will reach _debug_screen_change with the real screen # already in place, so reset_screen_to_defaults runs exactly once. @@ -226,6 +228,22 @@ class MainWindowUIMixin: pass 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, + ) + self.third_party_tools_screen = screen + return screen + + def _make_configure_tool_config_screen(self): + from jackify.frontends.gui.screens.configure_tool_config_screen import ConfigureToolConfigScreen + screen = ConfigureToolConfigScreen( + stacked_widget=self.stacked_widget, additional_tasks_index=3, + ) + self.configure_tool_config_screen = screen + return screen + def _debug_screen_change(self, index): try: idx = int(index) if index is not None else 0 @@ -253,6 +271,8 @@ class MainWindowUIMixin: 7: "Wabbajack Installer", 8: "Configure Existing Modlist", 9: "Install MO2 Screen", + 10: "Third Party Tools", + 11: "Configure Tool Compatibility", } screen_name = screen_names.get(idx, f"Unknown Screen (Index {idx})") widget = self.stacked_widget.widget(idx) diff --git a/jackify/frontends/gui/mixins/thread_lifecycle_mixin.py b/jackify/frontends/gui/mixins/thread_lifecycle_mixin.py new file mode 100644 index 0000000..6cca3ba --- /dev/null +++ b/jackify/frontends/gui/mixins/thread_lifecycle_mixin.py @@ -0,0 +1,98 @@ +""" +Safe QThread teardown mixin for workflow screens. + +PySide6 segfaults if a QThread emits a signal to a C++ Qt object that has +already been deleted (e.g. because the user navigated away). The fix is to +disconnect all signals from a thread before the owning screen can be destroyed, +then let the thread finish naturally rather than calling terminate(). + +Usage: + class MyScreen(ThreadLifecycleMixin, QWidget): + def hideEvent(self, event): + super().hideEvent(event) + self.my_thread = self._park_thread( + self.my_thread, ["finished_signal", "progress_update"] + ) + + def cleanup_processes(self): + self._park_all_threads() +""" + +import logging +from typing import List, Optional + +logger = logging.getLogger(__name__) + +# Module-level registry keeps references to parked threads alive independent +# of screen widget lifetime. Screens are destroyed on navigation; without this, +# _parked_threads on self evaporates and the GC destroys still-running threads, +# triggering Qt's "QThread: Destroyed while thread is still running" abort. +_PARKED_THREAD_REGISTRY: set = set() + + +class ThreadLifecycleMixin: + """Mixin providing safe QThread signal-disconnect parking for screen widgets.""" + + def _park_thread(self, thread, signal_names: Optional[List[str]] = None): + """Disconnect a thread from this screen and let it finish on its own. + + Disconnects the named signals so no callbacks fire on this (potentially + dying) widget. Keeps a reference in _parked_threads so the thread is + not garbage-collected before it finishes. + + Returns None so callers can do: self.thread = self._park_thread(self.thread, [...]) + """ + if thread is None: + return None + + for name in (signal_names or []): + 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. + _PARKED_THREAD_REGISTRY.add(thread) + try: + thread.finished.connect(lambda t=thread: _PARKED_THREAD_REGISTRY.discard(t)) + except Exception: + pass + return None + + def hideEvent(self, event): + """Park all running threads when the screen is hidden/navigated away from.""" + try: + super().hideEvent(event) + except Exception: + pass + self._park_all_threads() + + def _park_all_threads(self): + """Park every running QThread attribute found on this instance. + + Inspects instance variables, disconnects common signal names from any + running QThread, and parks them. Used in cleanup_processes() / closeEvent(). + """ + from PySide6.QtCore import QThread + + _common_signals = ( + "finished_signal", + "progress_update", + "workflow_complete", + "configuration_complete", + "error_occurred", + "status_update", + "finished", + ) + + for attr_name, value in list(vars(self).items()): + try: + if not isinstance(value, QThread): + continue + if not value.isRunning(): + continue + signal_names = [s for s in _common_signals if hasattr(value, s)] + setattr(self, attr_name, self._park_thread(value, signal_names)) + except Exception: + pass diff --git a/jackify/frontends/gui/screens/additional_tasks.py b/jackify/frontends/gui/screens/additional_tasks.py index cbff1cb..aba4dbb 100644 --- a/jackify/frontends/gui/screens/additional_tasks.py +++ b/jackify/frontends/gui/screens/additional_tasks.py @@ -96,6 +96,7 @@ class AdditionalTasksScreen(QWidget): ("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"), + ("Configure Tool Compatibility", "tool_config", "Apply xEdit, Pandora and DLL fixes to an existing modlist prefix"), ("Return to Main Menu", "return_main_menu", "Go back to the main menu"), ] @@ -153,6 +154,8 @@ class AdditionalTasksScreen(QWidget): 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 == "return_main_menu": @@ -185,6 +188,10 @@ class AdditionalTasksScreen(QWidget): "Check back later for more functionality!" ) + def _show_tool_config(self): + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(11) + 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 a23f265..e0534b9 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist.py @@ -33,8 +33,10 @@ 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 jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin class ConfigureExistingModlistScreen( + ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, ConfigureExistingModlistUIMixin, @@ -46,38 +48,25 @@ class ConfigureExistingModlistScreen( ): resize_request = Signal(str) - def _park_thread(self, thread, signal_names=None): - """Disconnect a running thread from this screen and keep it alive until it finishes.""" - if thread is None: - return None - signal_names = signal_names or [] - for signal_name in signal_names: + def hideEvent(self, event): + if getattr(self, '_vnv_controller', None) is not None: try: - getattr(thread, signal_name).disconnect() + self._vnv_controller.cleanup() except Exception: pass - if not hasattr(self, "_parked_threads"): - self._parked_threads = [] - self._parked_threads.append(thread) - self._parked_threads = [t for t in self._parked_threads if getattr(t, "isRunning", lambda: False)()] - return None + super().hideEvent(event) def cleanup_processes(self): """Clean up any running processes when the window closes or is cancelled""" - if hasattr(self, 'file_progress_list'): - self.file_progress_list.stop_cpu_tracking() - - from PySide6.QtCore import QThread - for attr_name, value in list(vars(self).items()): + if getattr(self, '_vnv_controller', None) is not None: try: - if isinstance(value, QThread) and value.isRunning(): - signal_names = [] - for candidate in ("finished_signal", "progress_update", "configuration_complete", "error_occurred"): - if hasattr(value, candidate): - signal_names.append(candidate) - setattr(self, attr_name, self._park_thread(value, signal_names)) + self._vnv_controller.cleanup() + self._vnv_controller = None except Exception: pass + if hasattr(self, 'file_progress_list'): + self.file_progress_list.stop_cpu_tracking() + self._park_all_threads() def cancel_and_cleanup(self): """Handle Cancel button - clean up processes and go back""" @@ -124,6 +113,14 @@ class ConfigureExistingModlistScreen( if install_dir: game_type = self._detect_game_type_from_mo2_ini(install_dir) + appid = getattr(self, '_current_appid', '') + if appid: + try: + from jackify.backend.handlers.modlist_handler import ModlistHandler + ModlistHandler().set_steam_grid_images(str(appid), install_dir, game_type=game_type) + logger.debug("Applied Steam artwork for appid %s", appid) + except Exception as e: + logger.warning("Failed to apply Steam artwork: %s", e) if game_type in ('falloutnv', 'fallout_new_vegas'): from jackify.backend.utils.modlist_meta import get_modlist_name identified_name = get_modlist_name(install_dir) diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py b/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py index 8bda5c7..4d4b480 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py @@ -15,34 +15,12 @@ class ConfigureExistingModlistWorkflowMixin: """Mixin providing workflow management for ConfigureExistingModlistScreen.""" def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str: - """Detect game type by checking ModOrganizer.ini for loader executables.""" - from pathlib import Path - - mo2_ini = Path(install_dir) / "ModOrganizer.ini" - if not mo2_ini.exists(): - return 'skyrim' # Fallback to most common - + """Detect special game type using the canonical ModlistHandler detection.""" try: - content = mo2_ini.read_text(encoding='utf-8', errors='ignore').lower() - - if 'skse64_loader.exe' in content or 'skyrim special edition' in content: - return 'skyrim' - elif 'f4se_loader.exe' in content or 'fallout 4' in content: - return 'fallout4' - elif 'nvse_loader.exe' in content or 'fallout new vegas' in content: - return 'falloutnv' - elif 'fose_loader.exe' in content or 'fallout 3' in content: - return 'fallout3' - elif 'obse_loader.exe' in content or 'oblivion' in content: - return 'oblivion' - elif 'starfield' in content: - return 'starfield' - elif 'enderal' in content: - return 'enderal' - else: - return 'skyrim' + from jackify.backend.handlers.modlist_handler import ModlistHandler + return ModlistHandler().detect_special_game_type(install_dir) or 'skyrim' except Exception as e: - logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}") + logger.warning("Game type detection failed, defaulting to skyrim: %s", e) return 'skyrim' def validate_and_start_configure(self): @@ -78,6 +56,7 @@ 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', '')) resolution = self.resolution_combo.currentText() # Handle resolution saving if resolution and resolution != "Leave unchanged": @@ -152,7 +131,7 @@ class ConfigureExistingModlistWorkflowMixin: modlist_context = ModlistContext( name=self.modlist_name, install_dir=Path(self.install_dir), - download_dir=Path(self.install_dir).parent / 'Downloads', # Default + download_dir=None, game_type=detected_game_type, nexus_api_key='', # Not needed for configuration-only modlist_value='', # Not needed for existing modlist @@ -187,7 +166,7 @@ class ConfigureExistingModlistWorkflowMixin: manual_steps_callback=manual_steps_callback, completion_callback=completion_callback ) - + if not success: self.error_occurred.emit( "Configuration did not complete successfully. " diff --git a/jackify/frontends/gui/screens/configure_new_modlist.py b/jackify/frontends/gui/screens/configure_new_modlist.py index 735dd7c..c4b93f1 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist.py +++ b/jackify/frontends/gui/screens/configure_new_modlist.py @@ -36,10 +36,11 @@ 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 jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin logger = logging.getLogger(__name__) -class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget): +class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget): resize_request = Signal(str) def cancel_and_cleanup(self): @@ -165,32 +166,7 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN def cleanup(self): """Clean up any running threads when the screen is closed""" - logger.debug("DEBUG: cleanup called - cleaning up threads") - if getattr(self, '_vnv_controller', None) is not None: self._vnv_controller.cleanup() self._vnv_controller = None - - # Clean up automated prefix thread if running - if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread and self.automated_prefix_thread.isRunning(): - logger.debug("DEBUG: Terminating AutomatedPrefixThread") - try: - self.automated_prefix_thread.progress_update.disconnect() - self.automated_prefix_thread.workflow_complete.disconnect() - self.automated_prefix_thread.error_occurred.disconnect() - except (RuntimeError, TypeError): - pass - self.automated_prefix_thread.terminate() - self.automated_prefix_thread.wait(2000) # Wait up to 2 seconds - - # Clean up config thread if running - if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning(): - logger.debug("DEBUG: Terminating ConfigThread") - try: - self.config_thread.progress_update.disconnect() - self.config_thread.configuration_complete.disconnect() - self.config_thread.error_occurred.disconnect() - except (RuntimeError, TypeError): - pass - self.config_thread.terminate() - self.config_thread.wait(2000) # Wait up to 2 seconds + self._park_all_threads() diff --git a/jackify/frontends/gui/screens/configure_new_modlist_console.py b/jackify/frontends/gui/screens/configure_new_modlist_console.py index a02c6e4..2738984 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_console.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_console.py @@ -4,7 +4,7 @@ import re import time from PySide6.QtCore import QTimer, Qt -from PySide6.QtWidgets import QFileDialog +from jackify.frontends.gui.utils import browse_file from jackify.shared.progress_models import FileProgress, OperationType from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL @@ -168,7 +168,7 @@ class ConfigureNewModlistConsoleMixin(FocusReclaimMixin): del self._component_install_list def browse_install_dir(self): - file, _ = QFileDialog.getOpenFileName(self, "Select ModOrganizer.exe", os.path.expanduser("~"), "ModOrganizer.exe (ModOrganizer.exe)") + file = browse_file(self, "Select ModOrganizer.exe", os.path.expanduser("~"), "ModOrganizer.exe (ModOrganizer.exe)") if file: - self.install_dir_edit.setText(os.path.realpath(file)) + self.install_dir_edit.setText(file) diff --git a/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py b/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py index a3f4c46..3dca77a 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py @@ -84,21 +84,26 @@ class ConfigureNewModlistDialogsMixin: except Exception: pass + def hideEvent(self, event): + if getattr(self, '_vnv_controller', None) is not None: + try: + self._vnv_controller.cleanup() + except Exception: + pass + super().hideEvent(event) + def cleanup_processes(self): """Clean up any running processes when the window closes or is cancelled""" + if getattr(self, '_vnv_controller', None) is not None: + try: + self._vnv_controller.cleanup() + self._vnv_controller = None + except Exception: + pass self._stop_focus_reclaim() if hasattr(self, 'file_progress_list'): self.file_progress_list.stop_cpu_tracking() - - from PySide6.QtCore import QThread - for attr_name, value in list(vars(self).items()): - try: - if isinstance(value, QThread) and value.isRunning(): - value.terminate() - value.wait(2000) - setattr(self, attr_name, None) - except Exception: - pass + self._park_all_threads() def show_shortcut_conflict_dialog(self, conflicts): """Show dialog to reuse an existing shortcut or choose a new name.""" @@ -148,6 +153,12 @@ class ConfigureNewModlistDialogsMixin: self._restore_controls_after_shortcut_dialog_abort() return self._safe_append_text(f"Reusing existing Steam shortcut '{existing_name}'.") + try: + from jackify.backend.handlers.modlist_handler import ModlistHandler + _game_type = self._detect_game_type_from_mo2_ini(install_dir) + ModlistHandler().set_steam_grid_images(str(existing_appid), install_dir, game_type=_game_type) + except Exception as _e: + logger.warning("Failed to apply Steam artwork on shortcut reuse: %s", _e) self.continue_configuration_after_automated_prefix( str(existing_appid), existing_name, diff --git a/jackify/frontends/gui/screens/configure_new_modlist_workflow.py b/jackify/frontends/gui/screens/configure_new_modlist_workflow.py index c75918d..ee17ee9 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_workflow.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_workflow.py @@ -251,6 +251,24 @@ class ConfigureNewModlistWorkflowMixin: logger.error("Error handling automated prefix result: %s", e) self._safe_append_text(f"Error handling automated prefix result: {str(e)}") self.start_btn.setEnabled(True) + finally: + self._cleanup_automated_prefix_thread() + + def _cleanup_automated_prefix_thread(self): + """Safely release the automated prefix thread after it has finished.""" + if not hasattr(self, 'automated_prefix_thread') or self.automated_prefix_thread is None: + return + try: + self.automated_prefix_thread.progress_update.disconnect() + self.automated_prefix_thread.workflow_complete.disconnect() + self.automated_prefix_thread.error_occurred.disconnect() + except (RuntimeError, TypeError): + pass + if self.automated_prefix_thread.isRunning(): + self.automated_prefix_thread.quit() + self.automated_prefix_thread.wait(5000) + self.automated_prefix_thread.deleteLater() + self.automated_prefix_thread = None def _on_automated_prefix_error(self, error): """Handle error from the automated prefix workflow""" @@ -261,8 +279,8 @@ class ConfigureNewModlistWorkflowMixin: logger.error(f"Automated prefix error: {error.message}") self._safe_append_text(f"[FAILED] {error.message}") MessageService.show_error(self, error) - self._enable_controls_after_operation() + self._cleanup_automated_prefix_thread() def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None): """Continue the configuration process with the new AppID after automated prefix creation""" @@ -327,7 +345,7 @@ class ConfigureNewModlistWorkflowMixin: modlist_context = ModlistContext( name=self.context['name'], install_dir=Path(self.context['path']), - download_dir=Path(self.context['path']).parent / 'Downloads', # Default + download_dir=None, game_type=detected_game_type, nexus_api_key='', # Not needed for configuration modlist_value=self.context.get('modlist_value'), @@ -434,7 +452,7 @@ class ConfigureNewModlistWorkflowMixin: modlist_context = ModlistContext( name=self.context['name'], install_dir=Path(self.context['path']), - download_dir=Path(self.context['path']).parent / 'Downloads', # Default + download_dir=None, game_type=detected_game_type, nexus_api_key='', # Not needed for configuration modlist_value='', # Not needed for existing modlist diff --git a/jackify/frontends/gui/screens/configure_tool_config_screen.py b/jackify/frontends/gui/screens/configure_tool_config_screen.py new file mode 100644 index 0000000..977da57 --- /dev/null +++ b/jackify/frontends/gui/screens/configure_tool_config_screen.py @@ -0,0 +1,442 @@ +""" +Configure Tool Compatibility screen. + +Applies Wine registry settings for modding tools (xEdit, Pandora, DLL overrides) +to an existing configured modlist prefix. Available from Additional Tasks. +""" + +import logging +import subprocess +from typing import Optional + +from PySide6.QtCore import Qt, QSize, QThread, QTimer, Signal +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QGridLayout, QHBoxLayout, QLabel, + QPushButton, QSizePolicy, QTabWidget, QTextEdit, QVBoxLayout, QWidget, +) + +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, DEBUG_BORDERS +from jackify.frontends.gui.utils import set_responsive_minimum + +logger = logging.getLogger(__name__) + + +class _ShortcutLoaderThread(QThread): + finished_signal = Signal(list) # list of {"name": str, "appid": str} + error_signal = Signal(str) + + def run(self): + try: + from jackify.backend.handlers.modlist_handler import ModlistHandler + 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") + ] + self.finished_signal.emit(shortcuts) + except Exception as e: + self.error_signal.emit(str(e)) + + +class _ApplyThread(QThread): + log_signal = Signal(str) + finished_signal = Signal(bool) + + def __init__(self, appid: str): + super().__init__() + self._appid = appid + + def run(self): + from jackify.backend.services.tool_config_service import apply_tool_config_for_appid + ok = apply_tool_config_for_appid(self._appid, log=self.log_signal.emit) + self.finished_signal.emit(ok) + + +class ConfigureToolConfigScreen(ThreadLifecycleMixin, QWidget): + """Apply tool compatibility settings to an existing modlist prefix.""" + + def __init__(self, stacked_widget=None, additional_tasks_index: int = 3, parent=None): + super().__init__(parent) + self.stacked_widget = stacked_widget + self.additional_tasks_index = additional_tasks_index + self.debug = DEBUG_BORDERS + self._shortcuts: list = [] + self._loader: Optional[_ShortcutLoaderThread] = None + self._apply_thread: Optional[_ApplyThread] = None + self._setup_ui() + + def _setup_ui(self): + main_vbox = QVBoxLayout(self) + main_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + main_vbox.setContentsMargins(50, 50, 50, 0) + main_vbox.setSpacing(12) + if self.debug: + self.setStyleSheet("border: 2px solid magenta;") + + # --- Header --- + header_widget = QWidget() + header_layout = QVBoxLayout() + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.setSpacing(2) + + title = QLabel("Configure Tool Compatibility") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};") + title.setAlignment(Qt.AlignHCenter) + header_layout.addWidget(title) + + header_layout.addSpacing(10) + + desc = QLabel( + "Applies Wine registry settings needed for modding tools to work correctly: " + "xEdit family (WinXP compatibility), Pandora (window decoration), " + "and global DLL overrides." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #ccc; font-size: 13px;") + desc.setAlignment(Qt.AlignHCenter) + desc.setMaximumHeight(50) + header_layout.addWidget(desc) + + header_layout.addSpacing(12) + header_widget.setLayout(header_layout) + header_widget.setFixedHeight(120) + if self.debug: + header_widget.setStyleSheet("border: 2px solid pink;") + main_vbox.addWidget(header_widget) + + # --- Upper section: form (left) + tabs (right) --- + upper_hbox = QHBoxLayout() + upper_hbox.setContentsMargins(0, 0, 0, 0) + upper_hbox.setSpacing(16) + + # Left: form + user_config_vbox = QVBoxLayout() + user_config_vbox.setAlignment(Qt.AlignTop) + user_config_vbox.setSpacing(4) + + options_header = QLabel("[Options]") + options_header.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; font-weight: bold;") + options_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + user_config_vbox.addWidget(options_header) + + form_grid = QGridLayout() + form_grid.setHorizontalSpacing(12) + form_grid.setVerticalSpacing(6) + form_grid.setContentsMargins(0, 0, 0, 0) + + modlist_label = QLabel("Modlist:") + form_grid.addWidget(modlist_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + + self._combo = QComboBox() + self._combo.setMinimumWidth(280) + self._combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self._combo.addItem("Loading modlists...") + self._combo.setEnabled(False) + form_grid.addWidget(self._combo, 0, 1) + + form_widget = QWidget() + form_widget.setLayout(form_grid) + form_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + user_config_vbox.addWidget(form_widget) + + user_config_vbox.addSpacing(10) + + tools_info = QLabel( + "Tools configured by this workflow:
" + "  xEdit family  |  Synthesis  |  Pandora
" + "
" + "Run this once after installing a modlist if modding tools are not " + "launching correctly from within Mod Organizer 2.
" + "
" + "These fixes are applied on a best-effort basis. Tool compatibility " + "can change with Proton and Wine updates. Not all tools are guaranteed " + "to work on all Proton versions." + ) + tools_info.setWordWrap(True) + tools_info.setStyleSheet("color: #aaa; font-size: 12px;") + tools_info.setAlignment(Qt.AlignLeft | Qt.AlignTop) + user_config_vbox.addWidget(tools_info) + + # Buttons (apply + back) - placed in left column like other screens + btn_row = QHBoxLayout() + btn_row.setAlignment(Qt.AlignHCenter) + + self._apply_btn = QPushButton("Apply Tool Configurations") + self._apply_btn.setEnabled(False) + btn_row.addWidget(self._apply_btn) + + self._back_btn = QPushButton("Back") + self._back_btn.clicked.connect(self._go_back) + btn_row.addWidget(self._back_btn) + + btn_row.insertStretch(0, 1) + btn_row.addStretch(1) + + self.show_details_checkbox = QCheckBox("Show details") + self.show_details_checkbox.setChecked(False) + self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output") + self.show_details_checkbox.toggled.connect(self._on_show_details_toggled) + + btn_row_widget = QWidget() + btn_row_widget.setLayout(btn_row) + btn_row_widget.setMaximumHeight(50) + if self.debug: + btn_row_widget.setStyleSheet("border: 2px solid red;") + self.btn_row_widget = btn_row_widget + + user_config_widget = QWidget() + user_config_widget.setLayout(user_config_vbox) + user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + if self.debug: + user_config_widget.setStyleSheet("border: 2px solid orange;") + + # Right: Activity + Process Monitor tabs + self._activity_log = QTextEdit() + self._activity_log.setReadOnly(True) + self._activity_log.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self._activity_log.setMinimumSize(QSize(300, 20)) + self._activity_log.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._activity_log.setStyleSheet( + f"background: #222; color: {JACKIFY_COLOR_BLUE}; " + "font-family: monospace; font-size: 11px; border: 1px solid #444;" + ) + + self.process_monitor = QTextEdit() + self.process_monitor.setReadOnly(True) + self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.process_monitor.setMinimumSize(QSize(300, 20)) + self.process_monitor.setStyleSheet( + f"background: #222; color: {JACKIFY_COLOR_BLUE}; " + "font-family: monospace; font-size: 11px; border: 1px solid #444;" + ) + + process_vbox = QVBoxLayout() + process_vbox.setContentsMargins(0, 0, 0, 0) + process_vbox.setSpacing(2) + process_vbox.addWidget(self.process_monitor) + process_monitor_widget = QWidget() + process_monitor_widget.setLayout(process_vbox) + process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + if self.debug: + process_monitor_widget.setStyleSheet("border: 2px solid purple;") + self.process_monitor_widget = process_monitor_widget + + self.activity_tabs = QTabWidget() + self.activity_tabs.setStyleSheet( + "QTabWidget::pane { background: #222; border: 1px solid #444; } " + "QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } " + "QTabBar::tab:selected { background: #333; color: #3fd0ea; } " + "QTabWidget { margin: 0px; padding: 0px; } " + "QTabBar { margin: 0px; padding: 0px; }" + ) + self.activity_tabs.setContentsMargins(0, 0, 0, 0) + self.activity_tabs.setDocumentMode(False) + self.activity_tabs.setTabPosition(QTabWidget.North) + if self.debug: + self.activity_tabs.setStyleSheet("border: 2px solid cyan;") + + self.activity_tabs.addTab(self._activity_log, "Activity") + self.activity_tabs.addTab(process_monitor_widget, "Process Monitor") + + upper_hbox.addWidget(user_config_widget, stretch=11) + upper_hbox.addWidget(self.activity_tabs, stretch=9) + upper_hbox.setAlignment(Qt.AlignTop) + + upper_section_widget = QWidget() + upper_section_widget.setLayout(upper_hbox) + upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + upper_section_widget.setMaximumHeight(280) + if self.debug: + upper_section_widget.setStyleSheet("border: 2px solid green;") + main_vbox.addWidget(upper_section_widget) + + # --- Status banner --- + self._status_banner = QLabel("Ready to apply") + self._status_banner.setAlignment(Qt.AlignCenter) + self._status_banner.setStyleSheet(f""" + background-color: #2a2a2a; + color: {JACKIFY_COLOR_BLUE}; + padding: 6px 8px; + border-radius: 4px; + font-weight: bold; + font-size: 13px; + """) + self._status_banner.setMaximumHeight(34) + self._status_banner.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + + banner_row = QHBoxLayout() + banner_row.setContentsMargins(0, 0, 0, 0) + banner_row.setSpacing(8) + banner_row.addWidget(self._status_banner, 1) + banner_row.addStretch() + banner_row.addWidget(self.show_details_checkbox) + banner_row_widget = QWidget() + banner_row_widget.setLayout(banner_row) + banner_row_widget.setMaximumHeight(45) + banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + main_vbox.addWidget(banner_row_widget) + + # --- Console (hidden by default) --- + self.console = QTextEdit() + self.console.setReadOnly(True) + self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.console.setMinimumHeight(0) + self.console.setMaximumHeight(0) + self.console.setFontFamily("monospace") + if self.debug: + self.console.setStyleSheet("border: 2px solid yellow;") + + main_vbox.addWidget(self.console, stretch=1) + main_vbox.addWidget(btn_row_widget, alignment=Qt.AlignHCenter) + + self.main_overall_vbox = main_vbox + self.setLayout(main_vbox) + + # Process monitor refresh timer + self._top_timer = QTimer(self) + self._top_timer.timeout.connect(self._update_top_panel) + self._top_timer.start(2000) + + self._apply_btn.clicked.connect(self._on_apply) + + # ------------------------------------------------------------------ + + def showEvent(self, event): + super().showEvent(event) + logger.info("Configure Tool Compatibility screen opened") + try: + main_window = self.window() + if main_window: + set_responsive_minimum(main_window, min_width=960, min_height=520) + except Exception: + pass + self._load_shortcuts() + + def _load_shortcuts(self): + if self._loader and self._loader.isRunning(): + return + self._combo.clear() + self._combo.addItem("Loading modlists...") + self._combo.setEnabled(False) + self._apply_btn.setEnabled(False) + self._loader = _ShortcutLoaderThread() + self._loader.finished_signal.connect(self._on_shortcuts_loaded) + self._loader.error_signal.connect(self._on_shortcuts_error) + self._loader.start() + + def _on_shortcuts_loaded(self, shortcuts: list): + self._shortcuts = shortcuts + self._combo.clear() + if not shortcuts: + self._combo.addItem("No configured modlists found") + self._combo.setEnabled(False) + self._apply_btn.setEnabled(False) + return + for s in shortcuts: + self._combo.addItem(s["name"]) + self._combo.setEnabled(True) + self._apply_btn.setEnabled(True) + + def _on_shortcuts_error(self, error: str): + self._combo.clear() + self._combo.addItem("Error loading modlists") + self._combo.setEnabled(False) + self._activity_log.append(f"Failed to load modlists: {error}") + + def _on_apply(self): + idx = self._combo.currentIndex() + if idx < 0 or idx >= len(self._shortcuts): + return + shortcut = self._shortcuts[idx] + appid = shortcut["appid"] + name = shortcut["name"] + + self._activity_log.clear() + self.console.clear() + self._activity_log.append(f"Applying tool configurations to: {name} (AppID {appid})") + self._status_banner.setText("Applying...") + logger.info("Applying tool compat config: %s (AppID %s)", name, appid) + self._apply_btn.setEnabled(False) + self._combo.setEnabled(False) + + self._apply_thread = _ApplyThread(appid) + self._apply_thread.log_signal.connect(self._activity_log.append) + self._apply_thread.log_signal.connect(self.console.append) + self._apply_thread.finished_signal.connect(self._on_apply_finished) + self._apply_thread.start() + + def _on_apply_finished(self, success: bool): + self._apply_thread = None + self._apply_btn.setEnabled(True) + self._combo.setEnabled(True) + if success: + self._activity_log.append("\nDone. Tool compatibility settings applied successfully.") + self._status_banner.setText("Applied successfully") + logger.info("Tool compat config applied successfully") + idx = self._combo.currentIndex() + name = self._shortcuts[idx]["name"] if 0 <= idx < len(self._shortcuts) else "Modlist" + from ..dialogs.success_dialog import SuccessDialog + success_dialog = SuccessDialog( + modlist_name=name, + workflow_type="tool_config", + time_taken="", + parent=self + ) + success_dialog.show() + else: + self._activity_log.append("\nFailed. Check the output above for details.") + self._status_banner.setText("Apply failed - see details") + logger.warning("Tool compat config failed") + MessageService.warning( + self, "Failed", + "Tool configuration did not complete successfully.\nSee the log for details." + ) + + def _go_back(self): + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(self.additional_tasks_index) + + def _on_show_details_toggled(self, checked: bool): + if checked: + self.console.setMinimumHeight(200) + self.console.setMaximumHeight(16777215) + self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + else: + self.console.setMinimumHeight(0) + self.console.setMaximumHeight(0) + self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + + def _update_top_panel(self): + try: + result = subprocess.run( + ["ps", "-eo", "pcpu,pmem,comm,args"], + stdout=subprocess.PIPE, text=True, timeout=2 + ) + lines = result.stdout.splitlines() + header = "CPU%\tMEM%\tCOMMAND" + filtered = [header] + rows = [] + for line in lines[1:]: + ll = line.lower() + if ( + "wine" in ll or "wine64" in ll or "protontricks" in ll + or "jackify-engine" in ll + ) and "jackify-gui.py" not in ll: + cols = line.strip().split(None, 3) + if len(cols) >= 3: + rows.append(cols) + rows.sort(key=lambda x: float(x[0]), reverse=True) + for cols in rows: + filtered.append("\t".join(cols)) + if len(filtered) == 1: + filtered.append("[No relevant processes]") + self.process_monitor.setPlainText("\n".join(filtered)) + except Exception as e: + self.process_monitor.setPlainText(f"[process info unavailable: {e}]") + + def cleanup_processes(self): + self._park_all_threads() diff --git a/jackify/frontends/gui/screens/install_mo2_screen.py b/jackify/frontends/gui/screens/install_mo2_screen.py index cff804a..1629c34 100644 --- a/jackify/frontends/gui/screens/install_mo2_screen.py +++ b/jackify/frontends/gui/screens/install_mo2_screen.py @@ -13,7 +13,7 @@ from typing import Optional from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QFileDialog, QLineEdit, QGridLayout, QTextEdit, QCheckBox, + QLineEdit, QGridLayout, QTextEdit, QCheckBox, QMessageBox, QSizePolicy, ) from PySide6.QtCore import Qt, QThread, Signal, QSize @@ -25,7 +25,7 @@ from jackify.shared.progress_models import FileProgress, OperationType from ..dialogs.existing_setup_dialog import prompt_existing_setup_dialog from ..services.message_service import MessageService from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS -from ..utils import set_responsive_minimum +from ..utils import set_responsive_minimum, browse_directory from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL from ..widgets.progress_indicator import OverallProgressIndicator from ..widgets.file_progress_list import FileProgressList @@ -303,11 +303,9 @@ class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget): self.resize_request.emit("compact") def _browse_folder(self): - folder = QFileDialog.getExistingDirectory( - self, "Select MO2 Installation Folder", str(Path.home()), QFileDialog.ShowDirsOnly - ) + folder = browse_directory(self, "Select MO2 Installation Folder", str(Path.home())) if folder: - self.install_dir_edit.setText(os.path.realpath(folder)) + self.install_dir_edit.setText(folder) # ------------------------------------------------------------------ # Activity window helpers @@ -511,9 +509,7 @@ class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget): try: if self.worker.isRunning(): self.worker.requestInterruption() - if not self.worker.wait(5000): - self.worker.terminate() - self.worker.wait(10000) + self.worker.wait(10000) self.worker.deleteLater() except Exception: pass diff --git a/jackify/frontends/gui/screens/install_modlist.py b/jackify/frontends/gui/screens/install_modlist.py index c6392f7..e52df4b 100644 --- a/jackify/frontends/gui/screens/install_modlist.py +++ b/jackify/frontends/gui/screens/install_modlist.py @@ -49,8 +49,9 @@ 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 jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin -class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin): +class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, 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)""" @@ -411,16 +412,25 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO btn_exit.clicked.connect(on_exit) dlg.exec() + def hideEvent(self, event): + if getattr(self, '_vnv_controller', None) is not None: + try: + self._vnv_controller.cleanup() + except Exception: + pass + super().hideEvent(event) + def cleanup_processes(self): """Clean up any running processes when the window closes or is cancelled""" - logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes") - if getattr(self, '_vnv_controller', None) is not None: self._vnv_controller.cleanup() self._vnv_controller = None self._stop_focus_reclaim() + # Disconnect all thread signals before any stopping - prevents callbacks to + # a dying widget if threads emit between now and actual termination. + self._park_all_threads() def _stop_thread(attr_name: str, cancel_method: Optional[str] = None, cooperative_ms: int = 5000, force_ms: int = 10000): thread = getattr(self, attr_name, None) @@ -460,16 +470,12 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO except Exception: pass - logger.warning(f"WARNING: {attr_name} did not stop in {cooperative_ms}ms, forcing terminate") + logger.warning(f"WARNING: {attr_name} did not stop in {cooperative_ms}ms, waiting for forced shutdown window") try: if cancel_method and hasattr(thread, cancel_method): getattr(thread, cancel_method)() except Exception: pass - try: - thread.terminate() - except Exception: - pass try: if not thread.wait(force_ms): logger.error(f"ERROR: {attr_name} still running after forced shutdown window") @@ -545,21 +551,18 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO self.install_thread.cancel() self.install_thread.wait(5000) - # Cancel the automated prefix thread if it exists - if hasattr(self, 'prefix_thread') and self.prefix_thread and self.prefix_thread.isRunning(): - self.prefix_thread.terminate() - self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown - if self.prefix_thread.isRunning(): - self.prefix_thread.terminate() # Force terminate if needed - self.prefix_thread.wait(1000) - - # Cancel the configuration thread if it exists - if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning(): - self.config_thread.terminate() - self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown - if self.config_thread.isRunning(): - self.config_thread.terminate() # Force terminate if needed - self.config_thread.wait(1000) + # Park prefix/config threads - disconnect their signals and let them + # finish naturally rather than terminating unsafely. + if hasattr(self, 'prefix_thread') and self.prefix_thread: + self.prefix_thread = self._park_thread( + self.prefix_thread, + ["progress_update", "workflow_complete", "error_occurred"], + ) + if hasattr(self, 'config_thread') and self.config_thread: + self.config_thread = self._park_thread( + self.config_thread, + ["progress_update", "configuration_complete", "error_occurred"], + ) # Cleanup any remaining processes self.cleanup_processes() diff --git a/jackify/frontends/gui/screens/install_modlist_configuration.py b/jackify/frontends/gui/screens/install_modlist_configuration.py index b07c65b..bc6d411 100644 --- a/jackify/frontends/gui/screens/install_modlist_configuration.py +++ b/jackify/frontends/gui/screens/install_modlist_configuration.py @@ -77,6 +77,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix def on_configuration_complete(self, success, message, modlist_name, enb_detected=False): """Handle configuration completion on main thread""" try: + install_dir = self.install_dir_edit.text().strip() # Stop CPU tracking now that everything is complete self.file_progress_list.stop_cpu_tracking() # Re-enable controls now that installation/configuration is complete @@ -107,7 +108,6 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix game_name = display_names.get(self._current_game_type, self._current_game_name) # Check for TTW eligibility before showing final success dialog - install_dir = self.install_dir_edit.text().strip() ttw_modlist_name = modlist_name try: from jackify.backend.utils.modlist_meta import get_modlist_name @@ -470,7 +470,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix modlist_context = ModlistContext( name=self.context['name'], install_dir=Path(self.context['path']), - download_dir=Path(self.context['path']).parent / 'Downloads', # Default + download_dir=None, game_type=detected_game_type, nexus_api_key='', # Not needed for configuration modlist_value=self.context.get('modlist_value'), @@ -603,7 +603,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix modlist_context = ModlistContext( name=self.context['name'], install_dir=Path(self.context['path']), - download_dir=Path(self.context['path']).parent / 'Downloads', # Default + download_dir=None, game_type=detected_game_type, nexus_api_key='', # Not needed for configuration modlist_value=self.context.get('modlist_value', ''), diff --git a/jackify/frontends/gui/screens/install_modlist_installer_thread.py b/jackify/frontends/gui/screens/install_modlist_installer_thread.py index a9c787d..bf450cd 100644 --- a/jackify/frontends/gui/screens/install_modlist_installer_thread.py +++ b/jackify/frontends/gui/screens/install_modlist_installer_thread.py @@ -35,7 +35,7 @@ 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, skip_disk_check=False): + oauth_info=None): super().__init__() self.modlist = modlist self.install_dir = install_dir @@ -48,7 +48,6 @@ class InstallerThread(QThread): self.progress_state_manager = progress_state_manager self.auth_service = auth_service self.oauth_info = oauth_info - self.skip_disk_check = skip_disk_check self._premium_signal_sent = False self._non_premium_info_sent = False self._engine_output_buffer = [] @@ -277,17 +276,17 @@ class InstallerThread(QThread): if debug_mode: cmd.append('--debug') logger.debug("DEBUG: Added --debug flag to jackify-engine command") - if self.skip_disk_check: - cmd.append('--skip-disk-check') - logger.debug("DEBUG: Added --skip-disk-check flag to jackify-engine command") logger.debug(f"DEBUG: FULL Engine command: {' '.join(cmd)}") logger.debug(f"DEBUG: 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 env = get_clean_subprocess_env(env_vars) # Install-time resource preflight: keep this visible in workflow output so @@ -472,6 +471,8 @@ class InstallerThread(QThread): self.output_received.emit(decoded) stderr_thread.join(timeout=5) returncode = self.process_manager.wait() + if writeback_path and self.auth_service: + self.auth_service.apply_token_writeback(writeback_path) if self.process_manager.proc and self.process_manager.proc.stdout: try: remaining = self.process_manager.proc.stdout.read() diff --git a/jackify/frontends/gui/screens/install_modlist_nexus.py b/jackify/frontends/gui/screens/install_modlist_nexus.py index a7f76f9..fc4fa18 100644 --- a/jackify/frontends/gui/screens/install_modlist_nexus.py +++ b/jackify/frontends/gui/screens/install_modlist_nexus.py @@ -258,7 +258,7 @@ class NexusAuthMixin: wait_label = QLabel( "Waiting for authorisation...\n\n" "Please complete authorisation in your browser.\n\n" - "Your browser may ask permission to open Jackify — click Open or Allow." + "Your browser may ask permission to open Jackify - click Open or Allow." ) wait_label.setWordWrap(True) wait_label.setStyleSheet("color: #ccc; font-size: 12px;") @@ -366,8 +366,6 @@ class NexusAuthMixin: oauth_thread.wait(100) if oauth_cancelled[0]: oauth_thread.wait(2000) - if oauth_thread.isRunning(): - oauth_thread.terminate() break wait_dialog.close() diff --git a/jackify/frontends/gui/screens/install_modlist_progress.py b/jackify/frontends/gui/screens/install_modlist_progress.py index efa3822..afc39d7 100644 --- a/jackify/frontends/gui/screens/install_modlist_progress.py +++ b/jackify/frontends/gui/screens/install_modlist_progress.py @@ -89,7 +89,7 @@ class ProgressHandlersMixin: "When your browser opens a Nexus page, click \"Slow Download\"." " For non-Nexus manual links, follow the site instructions shown in the page.

" "Watch folder: Jackify watches the folder shown in that dialog for newly downloaded files. " - "Files detected there are validated and moved automatically into your modlist downloads folder — " + "Files detected there are validated and moved automatically into your modlist downloads folder - " "you do not need to move files manually. If your browser saves to a different location, " "please set the Watch Folder to that directory before starting the download of mod archives." ) @@ -132,7 +132,7 @@ class ProgressHandlersMixin: self._stalled_download_start_time = time.time() self._stalled_data_snapshot = progress_state.data_processed elif progress_state.data_processed > self._stalled_data_snapshot: - # Bytes are advancing despite 0 speed readout — engine reporting lag, not a real stall + # Bytes are advancing despite 0 speed readout - engine reporting lag, not a real stall self._stalled_download_start_time = time.time() self._stalled_data_snapshot = progress_state.data_processed else: @@ -406,18 +406,6 @@ class ProgressHandlersMixin: if self._premium_failure_active: message = "Installation stopped because Nexus Premium is required for automated downloads." - if not self._premium_failure_active and not cancellation_detected: - thread = getattr(self, 'install_thread', None) - if (thread - and not getattr(thread, '_install_progress_started', False) - and getattr(getattr(thread, 'last_error', None), 'title', '') == "Disk Full"): - ctx = getattr(thread, '_last_error_raw_context', {}) - if self._handle_preflight_disk_space(ctx): - return - self._installation_cancelled = True - self.process_finished(130, QProcess.NormalExit) - return - if not self._premium_failure_active: engine_error = getattr(self.install_thread, 'last_error', None) if engine_error: @@ -429,98 +417,6 @@ class ProgressHandlersMixin: self._safe_append_text(f"\nError: {message}") self.process_finished(1, QProcess.CrashExit) # Simulate error - def _handle_preflight_disk_space(self, ctx: dict) -> bool: - """Show pre-flight filesystem warning dialog. Returns True if user chose Continue Anyway.""" - from PySide6.QtWidgets import QMessageBox - - if ctx.get('offending_names'): - name_max = ctx.get('name_max', 255) - offending_names = ctx.get('offending_names') or [] - examples = "\n".join(f" {n}" for n in offending_names[:3]) - if len(offending_names) > 3: - examples += f"\n ...and {len(offending_names) - 3} more" - body = ( - f"Your filesystem limits filenames to {name_max} characters, but this modlist " - f"contains files with longer names.\n\n" - f"Affected files:\n{examples}\n\n" - f"Installation may fail for those files. Using ext4, btrfs, or XFS on a " - f"non-encrypted mount is recommended.\n\n" - f"You can attempt to continue — some files may not extract correctly." - ) - dlg = QMessageBox(self) - dlg.setWindowTitle("Filename Length Warning") - dlg.setText("Filesystem filename length limit detected.") - dlg.setInformativeText(body) - dlg.setIcon(QMessageBox.Warning) - else: - archive_bytes = ctx.get('archive_bytes', 0) - install_bytes = ctx.get('install_bytes', 0) - same_drive = ctx.get('same_drive', False) - - def _fmt(b): - if b >= 1024 ** 3: - return f"{b / 1024 ** 3:.1f} GB" - if b >= 1024 ** 2: - return f"{b / 1024 ** 2:.1f} MB" - return f"{b} bytes" if b else "unknown" - - if same_drive: - space_lines = ( - f"Downloads and install are on the same drive.\n" - f"Archives require: {_fmt(archive_bytes)}\n" - f"Installed files require: {_fmt(install_bytes)}" - ) - else: - space_lines = ( - f"Download space required: {_fmt(archive_bytes)}\n" - f"Install space required: {_fmt(install_bytes)}" - ) - - body = ( - f"The disk space check reports that there may not be enough free space to complete " - f"this installation.\n\n" - f"{space_lines}\n\n" - f"If this is a modlist update, the actual space needed is likely far less — most files " - f"are already present and will be reused rather than re-downloaded.\n\n" - f"You can continue and free up space while downloads are running, " - f"or cancel to resolve the space issue first." - ) - dlg = QMessageBox(self) - dlg.setWindowTitle("Disk Space Warning") - dlg.setText("Not enough free disk space detected.") - dlg.setInformativeText(body) - dlg.setIcon(QMessageBox.Warning) - - continue_btn = dlg.addButton("Continue Anyway", QMessageBox.AcceptRole) - dlg.addButton("Cancel", QMessageBox.RejectRole) - dlg.setDefaultButton(continue_btn) - dlg.exec() - - if dlg.clickedButton() is not continue_btn: - return False - - thread = getattr(self, 'install_thread', None) - if not thread: - return False - - modlist = getattr(thread, 'modlist', None) - install_dir = getattr(thread, 'install_dir', None) - downloads_dir = getattr(thread, 'downloads_dir', None) - api_key = getattr(thread, 'api_key', None) - install_mode = getattr(thread, 'install_mode', 'online') - oauth_info = getattr(thread, 'oauth_info', None) - - if not (modlist and install_dir and downloads_dir and api_key): - return False - - logger.info("Pre-flight filesystem check bypassed by user — restarting with --skip-disk-check") - self._safe_append_text("\n[WARN] Filesystem check bypassed. Continuing installation...\n") - self.run_modlist_installer( - modlist, install_dir, downloads_dir, api_key, - install_mode, oauth_info, skip_disk_check=True, - ) - return True - def process_finished(self, exit_code, exit_status): logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}") # Reset button states @@ -600,6 +496,14 @@ class ProgressHandlersMixin: self._safe_append_text( f"Update mode: reusing existing Steam shortcut AppID {self._existing_shortcut_appid}." ) + try: + from jackify.backend.handlers.modlist_handler import ModlistHandler + _game_type = self._detect_game_type_from_mo2_ini(install_dir) + ModlistHandler().set_steam_grid_images( + str(self._existing_shortcut_appid), install_dir, game_type=_game_type + ) + except Exception as _e: + logger.warning("Failed to apply Steam artwork on update install: %s", _e) self.continue_configuration_after_automated_prefix( self._existing_shortcut_appid, modlist_name, diff --git a/jackify/frontends/gui/screens/install_modlist_selection.py b/jackify/frontends/gui/screens/install_modlist_selection.py index 347195a..cf5423d 100644 --- a/jackify/frontends/gui/screens/install_modlist_selection.py +++ b/jackify/frontends/gui/screens/install_modlist_selection.py @@ -1,6 +1,7 @@ """Modlist selection methods for InstallModlistScreen (Mixin).""" from pathlib import Path -from PySide6.QtWidgets import QFileDialog, QMessageBox, QApplication, QDialog +from PySide6.QtWidgets import QMessageBox, QApplication, QDialog +from jackify.frontends.gui.utils import browse_directory, browse_file from PySide6.QtCore import QTimer, Qt import logging import os @@ -38,6 +39,9 @@ class ModlistSelectionMixin: "Starfield": "starfield", "Oblivion Remastered": "oblivion_remastered", "Enderal": "enderal", + "Skyrim VR": "skyrimvr", + "Fallout 4 VR": "fallout4vr", + "Baldur's Gate 3": "bg3", "Other": "other" } cli_game_type = game_type_map.get(game_type, "other") @@ -139,6 +143,9 @@ class ModlistSelectionMixin: "Starfield": "Starfield", "Oblivion Remastered": "Oblivion", "Enderal": "Enderal Special Edition", + "Skyrim VR": "Skyrim VR", + "Fallout 4 VR": "Fallout 4 VR", + "Baldur's Gate 3": "Baldur's Gate 3", "Other": None } @@ -161,7 +168,8 @@ class ModlistSelectionMixin: 'game': metadata.gameHumanFriendly, 'description': metadata.description, 'nsfw': metadata.nsfw, - 'force_down': metadata.forceDown + 'force_down': metadata.forceDown, + 'readme_url': metadata.links.readme if metadata.links else None, } self.modlist_name_edit.setText(metadata.title) @@ -179,17 +187,17 @@ class ModlistSelectionMixin: self.modlist_btn.setEnabled(True) def browse_wabbajack_file(self): - file, _ = QFileDialog.getOpenFileName(self, "Select .wabbajack File", os.path.expanduser("~"), "Wabbajack Files (*.wabbajack)") + file = browse_file(self, "Select .wabbajack File", os.path.expanduser("~"), "Wabbajack Files (*.wabbajack)") if file: - self.file_edit.setText(os.path.realpath(file)) + self.file_edit.setText(file) def browse_install_dir(self): - dir = QFileDialog.getExistingDirectory(self, "Select Install Directory", self.install_dir_edit.text()) + dir = browse_directory(self, "Select Install Directory", self.install_dir_edit.text()) if dir: - self.install_dir_edit.setText(os.path.realpath(dir)) + self.install_dir_edit.setText(dir) def browse_downloads_dir(self): - dir = QFileDialog.getExistingDirectory(self, "Select Downloads Directory", self.downloads_dir_edit.text()) + dir = browse_directory(self, "Select Downloads Directory", self.downloads_dir_edit.text()) if dir: - self.downloads_dir_edit.setText(os.path.realpath(dir)) + self.downloads_dir_edit.setText(dir) diff --git a/jackify/frontends/gui/screens/install_modlist_ttw.py b/jackify/frontends/gui/screens/install_modlist_ttw.py index d89f004..5ae7282 100644 --- a/jackify/frontends/gui/screens/install_modlist_ttw.py +++ b/jackify/frontends/gui/screens/install_modlist_ttw.py @@ -101,7 +101,7 @@ class TTWIntegrationMixin: # Remember which screen to return to after TTW completes self._ttw_return_screen_index = self.stacked_widget.currentIndex() - # Navigate first — triggers lazy init and reset_screen_to_defaults. + # Navigate first - triggers lazy init and reset_screen_to_defaults. # set_modlist_integration_mode must be called AFTER so it overwrites # the default dir that reset_screen_to_defaults populates. self.stacked_widget.setCurrentIndex(5) diff --git a/jackify/frontends/gui/screens/install_modlist_ui_setup.py b/jackify/frontends/gui/screens/install_modlist_ui_setup.py index 2c7baba..ca60e7c 100644 --- a/jackify/frontends/gui/screens/install_modlist_ui_setup.py +++ b/jackify/frontends/gui/screens/install_modlist_ui_setup.py @@ -155,7 +155,7 @@ class InstallModlistUISetupMixin: online_layout = QHBoxLayout() online_layout.setContentsMargins(0, 0, 0, 0) # --- Game Type Selection --- - self.game_types = ["Skyrim", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal", "Other"] + self.game_types = ["Skyrim", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal", "Skyrim VR", "Fallout 4 VR", "Baldur's Gate 3", "Other"] self.game_type_btn = QPushButton("Please Select...") self.game_type_btn.setMinimumWidth(200) self.game_type_btn.clicked.connect(self.open_game_type_dialog) diff --git a/jackify/frontends/gui/screens/install_modlist_workflow_execution.py b/jackify/frontends/gui/screens/install_modlist_workflow_execution.py index 01e2056..63ec828 100644 --- a/jackify/frontends/gui/screens/install_modlist_workflow_execution.py +++ b/jackify/frontends/gui/screens/install_modlist_workflow_execution.py @@ -165,6 +165,8 @@ class InstallWorkflowExecutionMixin: # Handle resolution saving resolution = self.resolution_combo.currentText() if resolution and resolution != "Leave unchanged": + raw_resolution = resolution.split(" (")[0] if " (" in resolution else resolution + self._current_resolution = raw_resolution success = self.resolution_service.save_resolution(resolution) if success: logger.debug(f"DEBUG: Resolution saved successfully: {resolution}") @@ -185,9 +187,11 @@ class InstallWorkflowExecutionMixin: game_type = None game_name = None + readme_url = None if install_mode == 'file': # Parse .wabbajack file to get game type wabbajack_path = Path(modlist) + readme_url = self.wabbajack_parser.parse_wabbajack_readme(wabbajack_path) result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path) if result: if isinstance(result, tuple): @@ -221,6 +225,7 @@ class InstallWorkflowExecutionMixin: else: # For online modlists, try to get game type from selected modlist 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}'") @@ -233,8 +238,13 @@ class InstallWorkflowExecutionMixin: 'oblivion': 'oblivion', 'starfield': 'starfield', 'oblivion_remastered': 'oblivion_remastered', + 'oblivion remastered': 'oblivion_remastered', 'enderal': 'enderal', - 'enderal special edition': 'enderal' + 'enderal special edition': 'enderal', + 'skyrim vr': 'skyrimvr', + 'fallout 4 vr': 'fallout4vr', + 'cyberpunk 2077': 'cp2077', + "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}'") @@ -257,12 +267,16 @@ class InstallWorkflowExecutionMixin: if game_type and not is_supported: logger.debug(f"DEBUG: Game '{game_type}' is not supported, showing dialog") - # Show unsupported game dialog from ..widgets.unsupported_game_dialog import UnsupportedGameDialog dialog = UnsupportedGameDialog(self, game_name) if not dialog.show_dialog(self, game_name): self._abort_install_validation() return + elif game_type in ('skyrimvr', 'fallout4vr'): + from ..widgets.unsupported_game_dialog import UnsupportedGameDialog + if not UnsupportedGameDialog.show_dialog(self, game_name, vr_warning=True): + self._abort_install_validation() + return self.console.clear() self.process_monitor.clear() @@ -372,6 +386,20 @@ class InstallWorkflowExecutionMixin: ) return + if readme_url: + import subprocess + if "raw.githubusercontent.com" in readme_url: + readme_url = readme_url.replace("raw.githubusercontent.com", "github.com") + 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) + 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}') self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info) except Exception as e: @@ -384,7 +412,7 @@ class InstallWorkflowExecutionMixin: self.cancel_install_btn.setVisible(False) logger.debug(f"DEBUG: Controls re-enabled in exception handler") - def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None, skip_disk_check=False): + 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) @@ -409,7 +437,6 @@ class InstallWorkflowExecutionMixin: 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 - skip_disk_check=skip_disk_check, ) self.install_thread.output_received.connect(self.on_installation_output) self.install_thread.progress_received.connect(self.on_installation_progress) @@ -473,7 +500,7 @@ class InstallWorkflowExecutionMixin: self._safe_append_text( f"\n[Manual Download Required] {count} file(s) need manual download.\n" - f"Opening download dialog — check your taskbar if it does not appear in front.\n" + f"Opening download dialog - check your taskbar if it does not appear in front.\n" ) logger.info( f"[MDL-1006] Manual download protocol initialized | count={count} " diff --git a/jackify/frontends/gui/screens/install_ttw.py b/jackify/frontends/gui/screens/install_ttw.py index 3f69e71..f2f7fde 100644 --- a/jackify/frontends/gui/screens/install_ttw.py +++ b/jackify/frontends/gui/screens/install_ttw.py @@ -6,7 +6,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayo from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl from PySide6.QtGui import QPixmap, QTextCursor, QPainter, QFont from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS -from ..utils import ansi_to_html, strip_ansi_control_codes, set_responsive_minimum +from ..utils import ansi_to_html, strip_ansi_control_codes, set_responsive_minimum, _get_sidebar_urls from ..widgets.unsupported_game_dialog import UnsupportedGameDialog from jackify.frontends.gui.widgets.file_progress_list import FileProgressList import os @@ -37,6 +37,7 @@ from .install_ttw_workflow import TTWWorkflowMixin from .install_ttw_output import TTWOutputMixin from .install_ttw_ui import TTWUIMixin from .screen_back_mixin import ScreenBackMixin +from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin class ModlistFetchThread(QThread): result = Signal(list, str) @@ -77,7 +78,7 @@ class ModlistFetchThread(QThread): # Don't write to log file before workflow starts - just return error self.result.emit([], error_msg) -class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TTWRequirementsMixin, TTWLifecycleMixin, QWidget, TTWWorkflowMixin, TTWOutputMixin, TTWUIMixin): +class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TTWRequirementsMixin, TTWLifecycleMixin, QWidget, TTWWorkflowMixin, TTWOutputMixin, TTWUIMixin): resize_request = Signal(str) integration_complete = Signal(bool, str) # Signal for modlist integration completion (success, ttw_version) @@ -151,24 +152,24 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT pass def browse_wabbajack_file(self): - # Use QFileDialog instance to ensure consistent dialog style start_path = self.file_edit.text() if self.file_edit.text() else os.path.expanduser("~") dialog = QFileDialog(self, "Select TTW .mpi File") dialog.setFileMode(QFileDialog.ExistingFile) dialog.setNameFilter("MPI Files (*.mpi);;All Files (*)") dialog.setDirectory(start_path) - dialog.setOption(QFileDialog.DontUseNativeDialog, True) # Force Qt dialog for consistency + dialog.setOption(QFileDialog.DontUseNativeDialog, True) + dialog.setSidebarUrls(_get_sidebar_urls()) if dialog.exec() == QDialog.Accepted: files = dialog.selectedFiles() if files: self.file_edit.setText(files[0]) def browse_install_dir(self): - # Use QFileDialog instance to match file browser style exactly dialog = QFileDialog(self, "Select Install Directory") dialog.setFileMode(QFileDialog.Directory) dialog.setOption(QFileDialog.ShowDirsOnly, True) - dialog.setOption(QFileDialog.DontUseNativeDialog, True) # Force Qt dialog to match file browser + dialog.setOption(QFileDialog.DontUseNativeDialog, True) + dialog.setSidebarUrls(_get_sidebar_urls()) if self.install_dir_edit.text(): dialog.setDirectory(self.install_dir_edit.text()) if dialog.exec() == QDialog.Accepted: @@ -302,28 +303,13 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT def cleanup_processes(self): """Clean up any running processes when the window closes or is cancelled""" - logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes") - - # install_thread uses cancel() for cooperative shutdown before terminate. - if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning(): - logger.debug("DEBUG: Cancelling running InstallationThread") - self.install_thread.cancel() - self.install_thread.wait(3000) - if self.install_thread.isRunning(): - self.install_thread.terminate() - self.install_thread.wait(2000) - self.install_thread = None + # Disconnect all signals first - prevents callbacks to a dying widget. + self._park_all_threads() - from PySide6.QtCore import QThread - for attr_name, value in list(vars(self).items()): - if attr_name == 'install_thread': - continue + # install_thread gets a cooperative cancel signal on top of the park. + if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning(): try: - if isinstance(value, QThread) and value.isRunning(): - logger.debug(f"DEBUG: Terminating {attr_name}") - value.terminate() - value.wait(2000) - setattr(self, attr_name, None) + self.install_thread.cancel() except Exception: pass @@ -355,29 +341,13 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT font-size: 13px; """) - # 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(3000) # Wait up to 3 seconds for graceful shutdown - if self.install_thread.isRunning(): - self.install_thread.terminate() # Force terminate if needed - self.install_thread.wait(1000) - - # Cancel the automated prefix thread if it exists - if hasattr(self, 'prefix_thread') and self.prefix_thread and self.prefix_thread.isRunning(): - self.prefix_thread.terminate() - self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown - if self.prefix_thread.isRunning(): - self.prefix_thread.terminate() # Force terminate if needed - self.prefix_thread.wait(1000) - - # Cancel the configuration thread if it exists - if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning(): - self.config_thread.terminate() - self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown - if self.config_thread.isRunning(): - self.config_thread.terminate() # Force terminate if needed - self.config_thread.wait(1000) + # Park all threads first (disconnects signals), then send cooperative cancel. + self._park_all_threads() + if hasattr(self, 'install_thread') and self.install_thread: + try: + self.install_thread.cancel() + except Exception: + pass # 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 c8103d0..6feb16e 100644 --- a/jackify/frontends/gui/screens/install_ttw_integration.py +++ b/jackify/frontends/gui/screens/install_ttw_integration.py @@ -84,7 +84,7 @@ class TTWIntegrationMixin: ttw_output_dir.rename(versioned_path) ttw_output_dir = versioned_path skip_copy = True - logger.debug("TTW already in mods dir — skipping copy step") + logger.debug("TTW already in mods dir - skipping copy step") # Create background thread for integration class IntegrationThread(QThread): diff --git a/jackify/frontends/gui/screens/main_menu.py b/jackify/frontends/gui/screens/main_menu.py index 32729db..644438b 100644 --- a/jackify/frontends/gui/screens/main_menu.py +++ b/jackify/frontends/gui/screens/main_menu.py @@ -64,6 +64,7 @@ class MainMenu(QWidget): 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 ("Exit Jackify", "exit_jackify", "Close the application"), ] @@ -150,6 +151,8 @@ class MainMenu(QWidget): self.stacked_widget.setCurrentIndex(2) elif action_id == "additional_tasks" and self.stacked_widget: self.stacked_widget.setCurrentIndex(3) + elif action_id == "third_party_tools" and self.stacked_widget: + self.stacked_widget.setCurrentIndex(10) elif action_id == "return_main_menu": pass elif self.stacked_widget: diff --git a/jackify/frontends/gui/screens/modlist_gallery.py b/jackify/frontends/gui/screens/modlist_gallery.py index 36d215c..7653806 100644 --- a/jackify/frontends/gui/screens/modlist_gallery.py +++ b/jackify/frontends/gui/screens/modlist_gallery.py @@ -248,6 +248,40 @@ class ModlistGalleryDialog(ModlistGalleryFiltersMixin, ModlistGalleryLoadingMixi self.modlist_selected.emit(metadata) self.accept() + def closeEvent(self, event): + """Stop background threads before the dialog is destroyed.""" + # Stop the loading dot animation timer first + timer = getattr(self, '_loading_dot_timer', None) + if timer is not None: + timer.stop() + + for attr in ('_loader_thread', '_validation_thread'): + thread = getattr(self, attr, None) + if thread is None: + continue + # Disconnect all signals before terminating - prevents callbacks into + # a partially-destroyed dialog + try: + thread.disconnect() + except Exception: + pass + if thread.isRunning(): + # terminate() is required here: these threads run plain Python code + # with no Qt event loop, so quit() is a no-op and wait() alone + # would time out, leaving the thread running when the C++ QThread + # object is destroyed (which aborts the process). + thread.terminate() + thread.wait(3000) + + # Abort any pending image network requests + if hasattr(self, 'image_manager'): + try: + self.image_manager.network_manager.clearConnectionCache() + except Exception: + pass + + super().closeEvent(event) + # Re-export for backward compatibility __all__ = ['ImageManager', 'ModlistCard', 'ModlistDetailDialog', 'ModlistGalleryDialog'] diff --git a/jackify/frontends/gui/screens/screen_focus_reclaim.py b/jackify/frontends/gui/screens/screen_focus_reclaim.py index b34e8a2..2a74447 100644 --- a/jackify/frontends/gui/screens/screen_focus_reclaim.py +++ b/jackify/frontends/gui/screens/screen_focus_reclaim.py @@ -16,7 +16,7 @@ class FocusReclaimMixin: """ def _stop_focus_reclaim(self): - pass # No timer to stop — single-shot, no state + pass # No timer to stop - single-shot, no state def _start_focus_reclaim_retries(self): QTimer.singleShot(500, self._focus_reclaim_tick) diff --git a/jackify/frontends/gui/screens/third_party_tools.py b/jackify/frontends/gui/screens/third_party_tools.py new file mode 100644 index 0000000..a2d7a43 --- /dev/null +++ b/jackify/frontends/gui/screens/third_party_tools.py @@ -0,0 +1,478 @@ +""" +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/wabbajack_installer.py b/jackify/frontends/gui/screens/wabbajack_installer.py index 36c15c6..dfde649 100644 --- a/jackify/frontends/gui/screens/wabbajack_installer.py +++ b/jackify/frontends/gui/screens/wabbajack_installer.py @@ -13,7 +13,7 @@ from typing import Optional from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QFileDialog, QLineEdit, QGridLayout, QTextEdit, QTabWidget, QSizePolicy, QCheckBox, + QLineEdit, QGridLayout, QTextEdit, QTabWidget, QSizePolicy, QCheckBox, QMessageBox ) from PySide6.QtCore import Qt, QThread, Signal, QSize @@ -26,7 +26,7 @@ from ..dialogs.existing_setup_dialog import prompt_existing_setup_dialog from ..services.message_service import MessageService from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL -from ..utils import set_responsive_minimum +from ..utils import set_responsive_minimum, browse_directory from ..widgets.file_progress_list import FileProgressList from ..widgets.progress_indicator import OverallProgressIndicator from .screen_back_mixin import ScreenBackMixin @@ -367,13 +367,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget): def _browse_folder(self): """Browse for installation folder""" - folder = QFileDialog.getExistingDirectory( - self, - "Select Wabbajack Installation Folder", - str(Path.home()), - QFileDialog.ShowDirsOnly - ) - + folder = browse_directory(self, "Select Wabbajack Installation Folder", str(Path.home())) if folder: self.install_folder = Path(folder).resolve() self.install_dir_edit.setText(str(self.install_folder)) @@ -626,6 +620,15 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget): def cleanup_processes(self): self._stop_focus_reclaim() + if self.worker is not None: + try: + if self.worker.isRunning(): + self.worker.requestInterruption() + self.worker.wait(5000) + self.worker.deleteLater() + except Exception: + pass + self.worker = None def showEvent(self, event): """Called when widget becomes visible""" diff --git a/jackify/frontends/gui/services/message_service.py b/jackify/frontends/gui/services/message_service.py index 8e81374..259aff3 100644 --- a/jackify/frontends/gui/services/message_service.py +++ b/jackify/frontends/gui/services/message_service.py @@ -401,7 +401,7 @@ class _ErrorDialog(QDialog): self._detail_edit.hide() layout.addWidget(self._detail_edit) - # OK button — disabled for 3s to prevent accidental dismissal + # OK button - disabled for 3s to prevent accidental dismissal buttons = QDialogButtonBox(QDialogButtonBox.Ok) buttons.accepted.connect(self.accept) layout.addWidget(buttons) diff --git a/jackify/frontends/gui/services/vnv_automation_controller.py b/jackify/frontends/gui/services/vnv_automation_controller.py index 78e9d5f..13b8945 100644 --- a/jackify/frontends/gui/services/vnv_automation_controller.py +++ b/jackify/frontends/gui/services/vnv_automation_controller.py @@ -14,6 +14,11 @@ from PySide6.QtWidgets import QMessageBox, QWidget logger = logging.getLogger(__name__) +# Keep references to orphaned workers alive until they finish naturally. +# If cleanup() is called while a long-running worker (e.g. BSA decompression) +# is still going, dropping self._worker would let GC destroy a running QThread. +_ORPHANED_WORKERS: set = set() + class _VNVWorker(QThread): """Background thread for VNV automation.""" @@ -183,7 +188,7 @@ class VNVAutomationController(QObject): ) return True else: - # Nexus API unavailable — can't auto-track the download. + # Nexus API unavailable - can't auto-track the download. # Open the mod page so the user can get it manually and inform # them where to place it so the worker finds it next time. logger.warning("VNV non-premium: Nexus API query failed, cannot open download manager") @@ -195,7 +200,7 @@ class VNVAutomationController(QObject): from .message_service import MessageService MessageService.information( parent, - "VNV Tools — Manual Download Required", + "VNV Tools - Manual Download Required", "Jackify could not query the Nexus download URL(s) (check your Nexus login in Settings).\n\n" "Your modlist has been installed successfully.\n\n" "To complete VNV post-install setup, please:\n" @@ -204,7 +209,7 @@ class VNVAutomationController(QObject): "2. Download the BSA Decompressor package from:\n" " nexusmods.com/newvegas/mods/65854\n\n" f"3. Place the archive(s) in:\n {vnv_service.cache_dir}\n\n" - "4. Re-configure the modlist — Jackify will detect the files automatically.", + "4. Re-configure the modlist - Jackify will detect the files automatically.", ) return False @@ -224,7 +229,7 @@ class VNVAutomationController(QObject): return False def _dispatch_worker_start(self): - """Slot — always runs on the main thread due to queued signal delivery.""" + """Slot - always runs on the main thread due to queued signal delivery.""" if self._pending_worker_start: fn = self._pending_worker_start self._pending_worker_start = None @@ -254,7 +259,7 @@ class VNVAutomationController(QObject): def _on_all_done(_completed, _skipped): # _check_all_done() runs in the watcher background thread (Python - # threading.Thread — no Qt event loop). QTimer.singleShot is + # threading.Thread - no Qt event loop). QTimer.singleShot is # unreliable from non-Qt threads. Instead, emit a signal: because # VNVAutomationController was created on the main thread, Qt uses a # queued connection automatically and delivers the slot on the main thread. @@ -397,6 +402,9 @@ class VNVAutomationController(QObject): self._pending_worker_start = None self._stop_manual_download_flow() if self._worker and self._worker.isRunning(): - self._worker.terminate() - self._worker.wait(2000) - self._worker = None + # Worker may still be running a long operation (BSA decompression etc). + # Park it in the global set so the reference outlives this controller. + worker = self._worker + _ORPHANED_WORKERS.add(worker) + worker.finished.connect(lambda w=worker: _ORPHANED_WORKERS.discard(w)) + self._worker = None diff --git a/jackify/frontends/gui/utils.py b/jackify/frontends/gui/utils.py index 75c3402..ee83e26 100644 --- a/jackify/frontends/gui/utils.py +++ b/jackify/frontends/gui/utils.py @@ -1,10 +1,11 @@ """ GUI Utilities for Jackify Frontend """ +import os import re -from typing import Tuple, Optional -from PySide6.QtWidgets import QApplication, QWidget -from PySide6.QtCore import QSize, QPoint +from typing import Tuple, Optional, List +from PySide6.QtWidgets import QApplication, QWidget, QFileDialog +from PySide6.QtCore import QSize, QPoint, QUrl ANSI_COLOR_MAP = { '30': 'black', '31': 'red', '32': 'green', '33': 'yellow', '34': 'blue', '35': 'magenta', '36': 'cyan', '37': 'white', @@ -317,8 +318,53 @@ def apply_window_size_and_position( width, height = calculate_window_size( window, width_ratio, height_ratio, min_width, min_height, max_width, max_height ) - + # Calculate and set position pos = calculate_window_position(window, width, height, parent) window.resize(width, height) window.move(pos) + + +def _get_sidebar_urls() -> List[QUrl]: + """Return QUrl list for home dir plus any mounted volumes under /run/media/.""" + urls = [QUrl.fromLocalFile(os.path.expanduser("~"))] + run_media = "/run/media" + if os.path.isdir(run_media): + try: + for entry in os.scandir(run_media): + if entry.is_dir(): + # /run/media// - add the user subdir and all volumes + try: + for vol in os.scandir(entry.path): + if vol.is_dir(): + urls.append(QUrl.fromLocalFile(vol.path)) + except PermissionError: + urls.append(QUrl.fromLocalFile(entry.path)) + except PermissionError: + pass + return urls + + +def browse_directory(parent: QWidget, title: str, start_path: str = "") -> str: + """Open a directory browser dialog with SD card sidebar entries on Steam Deck.""" + dialog = QFileDialog(parent, title, start_path or os.path.expanduser("~")) + dialog.setFileMode(QFileDialog.Directory) + dialog.setOption(QFileDialog.ShowDirsOnly, True) + dialog.setSidebarUrls(_get_sidebar_urls()) + if dialog.exec(): + selected = dialog.selectedFiles() + return os.path.realpath(selected[0]) if selected else "" + return "" + + +def browse_file(parent: QWidget, title: str, start_path: str = "", file_filter: str = "") -> str: + """Open a file browser dialog with SD card sidebar entries on Steam Deck.""" + dialog = QFileDialog(parent, title, start_path or os.path.expanduser("~")) + dialog.setFileMode(QFileDialog.ExistingFile) + if file_filter: + dialog.setNameFilter(file_filter) + dialog.setSidebarUrls(_get_sidebar_urls()) + if dialog.exec(): + selected = dialog.selectedFiles() + return os.path.realpath(selected[0]) if selected else "" + return "" diff --git a/jackify/frontends/gui/widgets/file_progress_item.py b/jackify/frontends/gui/widgets/file_progress_item.py index 6f5eda3..881196c 100644 --- a/jackify/frontends/gui/widgets/file_progress_item.py +++ b/jackify/frontends/gui/widgets/file_progress_item.py @@ -149,7 +149,7 @@ class FileProgressItem(QWidget): def _set_indeterminate(self): if not self._is_indeterminate: self._is_indeterminate = True - # Qt's QProgressStyleAnimation drives this automatically — no manual timer needed + # Qt's QProgressStyleAnimation drives this automatically - no manual timer needed self.progress_bar.setRange(0, 0) self.percent_label.setText("") diff --git a/jackify/frontends/gui/widgets/file_progress_list.py b/jackify/frontends/gui/widgets/file_progress_list.py index 064ff58..a0677d9 100644 --- a/jackify/frontends/gui/widgets/file_progress_list.py +++ b/jackify/frontends/gui/widgets/file_progress_list.py @@ -25,7 +25,7 @@ __all__ = ['SummaryProgressWidget', 'FileProgressItem', 'FileProgressList'] class _CpuWorker(QThread): - """Background worker for CPU usage sampling — keeps psutil off the main thread.""" + """Background worker for CPU usage sampling - keeps psutil off the main thread.""" result = Signal(str) caches_updated = Signal(object, object, float) # process_cache, child_cache, smoothed_pct @@ -53,7 +53,7 @@ class _CpuWorker(QThread): try: current_child_pids.add(child.pid) if child.pid not in self._child_cache: - # Baseline in background — no longer blocks main thread + # Baseline in background - no longer blocks main thread child.cpu_percent(interval=0.1) self._child_cache[child.pid] = child continue @@ -173,7 +173,7 @@ class FileProgressList(QWidget): self._last_update_time = 0.0 - # CPU usage tracking — worker thread to avoid blocking the main thread + # CPU usage tracking - worker thread to avoid blocking the main thread self._cpu_timer = QTimer(self) self._cpu_timer.timeout.connect(self._start_cpu_worker) self._cpu_timer.setInterval(2000) @@ -342,9 +342,7 @@ class FileProgressList(QWidget): self._cpu_timer.stop() if self._cpu_worker and self._cpu_worker.isRunning(): self._cpu_worker.quit() - if not self._cpu_worker.wait(500): - self._cpu_worker.terminate() - self._cpu_worker.wait(1000) + self._cpu_worker.wait(1000) self._cpu_worker = None def _start_cpu_worker(self): diff --git a/jackify/frontends/gui/widgets/unsupported_game_dialog.py b/jackify/frontends/gui/widgets/unsupported_game_dialog.py index 1df6686..8449bdf 100644 --- a/jackify/frontends/gui/widgets/unsupported_game_dialog.py +++ b/jackify/frontends/gui/widgets/unsupported_game_dialog.py @@ -24,15 +24,16 @@ class UnsupportedGameDialog(QDialog): # Signal emitted when user clicks OK to continue continue_installation = Signal() - def __init__(self, parent=None, game_name: str = None): + def __init__(self, parent=None, game_name: str = None, vr_warning: bool = False): super().__init__(parent) self.game_name = game_name + self.vr_warning = vr_warning self.setup_ui() self.setup_connections() - + def setup_ui(self): """Set up the dialog UI.""" - self.setWindowTitle("Game Support Notice") + self.setWindowTitle("VR Platform Notice" if self.vr_warning else "Game Support Notice") self.setModal(True) self.setFixedSize(500, 500) @@ -49,7 +50,7 @@ class UnsupportedGameDialog(QDialog): icon_label.setFixedSize(32, 32) icon_label.setStyleSheet("color: #e67e22;") title_layout.addWidget(icon_label) - title_label = QLabel("Game Support Notice") + title_label = QLabel("VR Platform Notice" if self.vr_warning else "Game Support Notice") title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) title_label.setStyleSheet("color: #3fd0ea;") title_layout.addWidget(title_label) @@ -82,7 +83,24 @@ class UnsupportedGameDialog(QDialog): """) # Create the message content - if self.game_name: + if self.vr_warning: + game_label = self.game_name or "a VR modlist" + message = f"""

You are about to install {game_label}.

+ +

Jackify will handle the download, Wine prefix setup, and Steam shortcut creation as normal. However, getting VR modlists running on Linux involves platform dependencies that are outside Jackify's control:

+ +
    +
  • SteamVR must be installed and working with your headset before launching
  • +
  • Your VR runtime (SteamVR, ALVR, WiVRn, etc.) must be configured separately
  • +
  • Some modlists may require additional manual steps documented by the list author
  • +
+ +

Jackify's VR support is best effort. The install and configuration will proceed normally, but whether the modlist runs correctly depends heavily on your VR platform setup.

+ +

Always consult your modlist's installation guide for any additional manual steps required after Jackify completes.

+ +

Click Continue to proceed, or Cancel to go back.

""" + elif self.game_name: message = f"""

You are about to install a modlist for {self.game_name}.

While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to:

@@ -187,17 +205,18 @@ class UnsupportedGameDialog(QDialog): self.accepted.connect(self.continue_installation.emit) @staticmethod - def show_dialog(parent=None, game_name: str = None) -> bool: + def show_dialog(parent=None, game_name: str = None, vr_warning: bool = False) -> bool: """ - Show the unsupported game dialog and return the user's choice. - + Show the dialog and return the user's choice. + Args: parent: Parent widget - game_name: Name of the unsupported game (optional) - + game_name: Name of the game (optional) + vr_warning: Show VR best-effort warning instead of unsupported game notice + Returns: True if user clicked Continue, False if Cancel """ - dialog = UnsupportedGameDialog(parent, game_name) + dialog = UnsupportedGameDialog(parent, game_name, vr_warning=vr_warning) result = dialog.exec() return result == QDialog.DialogCode.Accepted \ No newline at end of file diff --git a/jackify/shared/errors.py b/jackify/shared/errors.py index 084a668..bacacaa 100644 --- a/jackify/shared/errors.py +++ b/jackify/shared/errors.py @@ -136,7 +136,7 @@ def _logs_dir_display() -> str: # --------------------------------------------------------------------------- # Factory functions for known failure modes. -# No GUI imports allowed here — backend code raises these directly. +# No GUI imports allowed here - backend code raises these directly. # --------------------------------------------------------------------------- def steam_still_running() -> SteamError: @@ -280,8 +280,8 @@ def ttw_install_failed(detail: str) -> TTWError: solutions=[ "Confirm vanilla Fallout 3 and Fallout New Vegas are both installed and launch correctly.", "If either game was previously modded, restore a clean vanilla install before retrying TTW.", - "Ensure TTW_Linux_Installer is installed — use 'Install TTW Installer' in Additional Tasks.", - "Check available disk space — TTW requires ~15GB free.", + "Ensure TTW_Linux_Installer is installed - use 'Install TTW Installer' in Additional Tasks.", + "Check available disk space - TTW requires ~15GB free.", "Verify the TTW .mpi file is not corrupted (try re-downloading it).", f"Check Jackify logs ({_logs_dir_display()}) and TTW_Install_workflow.log for the specific failure.", f"If this still fails, open a GitHub issue and include logs from {_logs_dir_display()}.", @@ -296,10 +296,10 @@ def wabbajack_install_failed(detail: str, context: Optional[dict] = None) -> Ins message="The modlist installation did not complete successfully.", suggestion=f"Check the console output and Jackify logs ({_logs_dir_display()}) for the failure reason.", solutions=[ - "Ensure you are logged in to Nexus Mods — check Settings > OAuth.", + "Ensure you are logged in to Nexus Mods - check Settings > OAuth.", "Confirm your Nexus account has Premium access for automated downloads.", "Check available disk space on both the install and download drives.", - "Re-run the install — Wabbajack resumes from where it stopped.", + "Re-run the install - Wabbajack resumes from where it stopped.", "If a specific file failed repeatedly, try downloading it manually from Nexus.", "Check Modlist_Install_workflow.log for the specific file that failed.", "If the same failure repeats with no clear workaround, open a GitHub issue with logs.", @@ -333,7 +333,7 @@ def install_dir_create_failed(path: str, detail: str) -> InstallError: "Confirm the target drive is mounted and writable.", "Check available disk space: df -h", "Try creating the folder manually first, then retry.", - "On Steam Deck, avoid paths under /usr or /var — use /home/deck or an SD card.", + "On Steam Deck, avoid paths under /usr or /var - use /home/deck or an SD card.", ], technical=format_technical_context(detail=detail, context={"path": path}), ) @@ -368,7 +368,7 @@ def cc_content_missing(filename: str = "") -> InstallError: "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.", + "Note: Skyrim AE via Steam Family Sharing does not transfer DLC content - you must own AE directly.", ], technical=format_technical_context(detail=detail) if detail else None, ) @@ -386,9 +386,9 @@ def creation_kit_missing() -> InstallError: "In Steam, search for 'Skyrim Special Edition: Creation Kit' 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.", + "When asked whether to unzip Scripts.zip, select NO - unzipping will cause the CK to crash.", "Once the Creation Kit opens successfully, close it.", - "Re-run the modlist install in Jackify — the required files will now be in place.", + "Re-run the modlist install in Jackify - the required files will now be in place.", ], ) @@ -399,7 +399,7 @@ def mo2_setup_failed(detail: str) -> InstallError: message="Jackify could not complete the Mod Organizer 2 setup.", suggestion=f"Check Jackify logs ({_logs_dir_display()}) for the specific failure.", solutions=[ - "Ensure you have an active internet connection — MO2 is downloaded from GitHub.", + "Ensure you have an active internet connection - MO2 is downloaded from GitHub.", "Check available disk space in the install directory.", "Try selecting a different install directory with full write permissions.", "If the download failed, check GitHub is accessible (try opening it in a browser).", @@ -423,10 +423,10 @@ _PATTERNS: List[tuple] = [ ("no such file or directory.*compatdata", lambda d: proton_not_found()), ("proton.*not found|no proton", lambda d: proton_not_found()), ("vdf.*error|binary_vdf|invalid vdf", lambda d: SteamError("Steam VDF File Error", "A Steam configuration file (VDF) could not be read or written.", suggestion="Ensure Steam is closed and try again.", solutions=["Close Steam completely before retrying.", "Restart Steam and retry the same action.", f"Check Jackify logs ({_logs_dir_display()}) for the specific VDF path.", f"If this still fails, open a GitHub issue and include logs from {_logs_dir_display()}."], technical=format_technical_context(detail=d))), - ("connection.*refused|connection.*timed out|network.*unreachable", lambda d: InstallError("Network Error", "Jackify could not reach a required network resource.", suggestion="Check your internet connection and retry.", solutions=["Verify your internet connection is active.", "Check if Nexus Mods is reachable at nexusmods.com.", "Disable VPN or proxy if active.", "Retry — transient network errors often resolve on the second attempt."], technical=format_technical_context(detail=d))), + ("connection.*refused|connection.*timed out|network.*unreachable", lambda d: InstallError("Network Error", "Jackify could not reach a required network resource.", suggestion="Check your internet connection and retry.", solutions=["Verify your internet connection is active.", "Check if Nexus Mods is reachable at nexusmods.com.", "Disable VPN or proxy if active.", "Retry - transient network errors often resolve on the second attempt."], technical=format_technical_context(detail=d))), ("401|unauthorized|forbidden.*nexus", lambda d: oauth_expired()), - ("7z.*error|bad archive|cannot open.*archive", lambda d: InstallError("Archive Error", "A downloaded archive file is corrupted or unreadable.", suggestion="Delete the corrupted file and re-run the install to re-download it.", solutions=["Re-run the install — Wabbajack will re-download files that fail verification.", "Check available disk space (partial downloads look corrupt).", "Check Modlist_Install_workflow.log for the specific file name."], technical=format_technical_context(detail=d))), - ("timeout", lambda d: SteamError("Operation Timed Out", "An operation took longer than expected and was stopped.", suggestion="Retry — timeouts are often transient.", solutions=["Retry the operation.", "If Steam is slow to start, give it more time before retrying.", "Check system load: close other applications.", f"Check Jackify logs ({_logs_dir_display()}) for which step timed out."], technical=format_technical_context(detail=d))), + ("7z.*error|bad archive|cannot open.*archive", lambda d: InstallError("Archive Error", "A downloaded archive file is corrupted or unreadable.", suggestion="Delete the corrupted file and re-run the install to re-download it.", solutions=["Re-run the install - Wabbajack will re-download files that fail verification.", "Check available disk space (partial downloads look corrupt).", "Check Modlist_Install_workflow.log for the specific file name."], technical=format_technical_context(detail=d))), + ("timeout", lambda d: SteamError("Operation Timed Out", "An operation took longer than expected and was stopped.", suggestion="Retry - timeouts are often transient.", solutions=["Retry the operation.", "If Steam is slow to start, give it more time before retrying.", "Check system load: close other applications.", f"Check Jackify logs ({_logs_dir_display()}) for which step timed out."], technical=format_technical_context(detail=d))), ] diff --git a/jackify/shared/logging.py b/jackify/shared/logging.py index cac2495..a9da605 100644 --- a/jackify/shared/logging.py +++ b/jackify/shared/logging.py @@ -214,4 +214,56 @@ class LoggingHandler: def get_general_logger(self): """Get the general CLI logger ({jackify_data_dir}/logs/jackify-cli.log).""" - return self.setup_logger('jackify_cli', is_general=True) \ No newline at end of file + return self.setup_logger('jackify_cli', is_general=True) + + def setup_application_logging(self, debug_mode: bool = False) -> logging.Logger: + """Configure the root logger for the application. + + Always-on: jackify.log at INFO level. + Debug mode only: jackify-debug.log at DEBUG level. + Console: WARNING in both modes. + + Call once at application startup before any other loggers are created. + """ + root = logging.getLogger() + # Clear any handlers set by basicConfig or previous calls + root.handlers.clear() + # Root must pass everything through - handlers do the filtering + root.setLevel(logging.DEBUG) + root.propagate = False + + file_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + console_formatter = logging.Formatter('%(levelname)s: %(message)s') + + # Always-on application log at INFO - rotate on every startup, keep 5 + app_log_path = self.log_dir / 'jackify.log' + self.rotate_log_file_per_run(app_log_path) + app_handler = logging.handlers.RotatingFileHandler( + app_log_path, mode='a', encoding='utf-8', + maxBytes=10 * 1024 * 1024, backupCount=5 + ) + app_handler.setLevel(logging.INFO) + app_handler.setFormatter(file_formatter) + root.addHandler(app_handler) + + # Debug log only when explicitly enabled + if debug_mode: + debug_log_path = self.log_dir / 'jackify-debug.log' + self.rotate_log_file_per_run(debug_log_path) + debug_handler = logging.handlers.RotatingFileHandler( + debug_log_path, mode='a', encoding='utf-8', + maxBytes=100 * 1024 * 1024, backupCount=5 + ) + debug_handler.setLevel(logging.DEBUG) + debug_handler.setFormatter(file_formatter) + root.addHandler(debug_handler) + + # Console: errors only - warnings and below go to jackify.log + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.ERROR) + console_handler.setFormatter(console_formatter) + root.addHandler(console_handler) + + return root \ No newline at end of file diff --git a/jackify/shared/paths.py b/jackify/shared/paths.py index d637dd5..2293b23 100644 --- a/jackify/shared/paths.py +++ b/jackify/shared/paths.py @@ -74,18 +74,17 @@ def cleanup_stale_tmp() -> None: The engine writes TTW working files (xd3 patches, patch manifests) into UUID-named subdirectories under /.tmp/ during TTW installation. These are never cleaned up on failure or interruption and can accumulate - several GB per run. Any such directory present at startup is always stale — + several GB per run. Any such directory present at startup is always stale - no TTW install can be in flight before the application has started. - Only removes directories matching known engine temp prefixes. The - jackify-proton-extraction prefix is intentionally reused by the engine - across runs and is left in place. + jackify-proton-extraction is also cleaned up - the engine no longer uses it + and it can accumulate ~700MB. """ tmp_dir = get_jackify_data_dir() / ".tmp" if not tmp_dir.is_dir(): return - stale_prefixes = ("ttw_mpi_", "ttw_ogg_") + stale_prefixes = ("ttw_mpi_", "ttw_ogg_", "jackify-proton-extraction") removed = 0 freed = 0 diff --git a/jackify/tools/winetricks b/jackify/tools/winetricks index e627f71..c2e7110 100755 --- a/jackify/tools/winetricks +++ b/jackify/tools/winetricks @@ -1456,6 +1456,7 @@ winetricks_selfupdate() w_try cp "$0" "${_W_rollback_file}" w_try chmod -x "${_W_rollback_file}" + # Uses mv to avoid race condition in overwriting running shell script. Do not replace with cp or other non-atomic writes! w_try mv "${_W_update_file}" "$0" w_try chmod +x "$0" @@ -1471,6 +1472,7 @@ winetricks_selfupdate_rollback() _W_rollback_file="${0}.bak" if test -f "${_W_rollback_file}"; then + # Uses mv to avoid race condition in overwriting running shell script. Do not replace with cp or other non-atomic writes! w_try mv "${_W_rollback_file}" "$0" w_try chmod +x "$0" w_warn "Rollback finished! The current version is $($0 -V)." @@ -5984,7 +5986,7 @@ load_d3dcompiler_43() w_metadata d3dcompiler_46 dlls \ title="MS d3dcompiler_46.dll" \ publisher="Microsoft" \ - year="2010" \ + year="2012" \ media="download" \ file1="../directx9/directx_Jun2010_redist.exe" \ installed_file1="${W_SYSTEM32_DLLS_WIN}/d3dcompiler_46.dll" @@ -5993,12 +5995,12 @@ load_d3dcompiler_46() { # See https://bugs.winehq.org/show_bug.cgi?id=50350#c13 - w_download http://download.microsoft.com/download/F/1/3/F1300C9C-A120-4341-90DF-8A52509B23AC/standalonesdk/Installers/2630bae9681db6a9f6722366f47d055c.cab + w_download https://download.microsoft.com/download/F/1/3/F1300C9C-A120-4341-90DF-8A52509B23AC/standalonesdk/Installers/2630bae9681db6a9f6722366f47d055c.cab w_try_cabextract -d "${W_TMP}" -L -F "fil47ed91e900f4b9d9659b66a211b57c39" "${W_CACHE}/${W_PACKAGE}/2630bae9681db6a9f6722366f47d055c.cab" w_try mv "${W_TMP}/fil47ed91e900f4b9d9659b66a211b57c39" "${W_SYSTEM32_DLLS}/d3dcompiler_46.dll" if [ "${W_ARCH}" = "win64" ]; then - w_download http://download.microsoft.com/download/F/1/3/F1300C9C-A120-4341-90DF-8A52509B23AC/standalonesdk/Installers/61d57a7a82309cd161a854a6f4619e52.cab + w_download https://download.microsoft.com/download/F/1/3/F1300C9C-A120-4341-90DF-8A52509B23AC/standalonesdk/Installers/61d57a7a82309cd161a854a6f4619e52.cab w_try_cabextract -d "${W_TMP}" -L -F "fil8c20206095817436f8df4a711faee5b7" "${W_CACHE}/${W_PACKAGE}/61d57a7a82309cd161a854a6f4619e52.cab" w_try mv "${W_TMP}/fil8c20206095817436f8df4a711faee5b7" "${W_SYSTEM64_DLLS}/d3dcompiler_46.dll" fi @@ -6011,19 +6013,21 @@ load_d3dcompiler_46() w_metadata d3dcompiler_47 dlls \ title="MS d3dcompiler_47.dll" \ publisher="Microsoft" \ - year="FIXME" \ + year="2013" \ media="download" \ - file1="d3dcompiler_47_32.dll" \ + file1="../directx9/directx_Jun2010_redist.exe" \ installed_file1="${W_SYSTEM32_DLLS_WIN}/d3dcompiler_47.dll" load_d3dcompiler_47() { - w_download https://raw.githubusercontent.com/mozilla/fxc2/master/dll/d3dcompiler_47_32.dll 2ad0d4987fc4624566b190e747c9d95038443956ed816abfd1e2d389b5ec0851 - w_try_cp_dll "${W_CACHE}/d3dcompiler_47/d3dcompiler_47_32.dll" "${W_SYSTEM32_DLLS}/d3dcompiler_47.dll" + w_download https://download.microsoft.com/download/B/0/C/B0C80BA3-8AD6-4958-810B-6882485230B5/standalonesdk/Installers/2630bae9681db6a9f6722366f47d055c.cab + w_try_cabextract -d "${W_TMP}" -L -F "fila319f706acfa16d6707473ebf29bdc7f" "${W_CACHE}/${W_PACKAGE}/2630bae9681db6a9f6722366f47d055c.cab" + w_try mv "${W_TMP}/fila319f706acfa16d6707473ebf29bdc7f" "${W_SYSTEM32_DLLS}/d3dcompiler_47.dll" if [ "${W_ARCH}" = "win64" ]; then - w_download https://raw.githubusercontent.com/mozilla/fxc2/master/dll/d3dcompiler_47.dll 4432bbd1a390874f3f0a503d45cc48d346abc3a8c0213c289f4b615bf0ee84f3 - w_try_cp_dll "${W_CACHE}/d3dcompiler_47/d3dcompiler_47.dll" "${W_SYSTEM64_DLLS}/d3dcompiler_47.dll" + w_download https://download.microsoft.com/download/B/0/C/B0C80BA3-8AD6-4958-810B-6882485230B5/standalonesdk/Installers/61d57a7a82309cd161a854a6f4619e52.cab + w_try_cabextract -d "${W_TMP}" -L -F "fil3585cb2ea5db13cc0838f8d06b5c9679" "${W_CACHE}/${W_PACKAGE}/61d57a7a82309cd161a854a6f4619e52.cab" + w_try mv "${W_TMP}/fil3585cb2ea5db13cc0838f8d06b5c9679" "${W_SYSTEM64_DLLS}/d3dcompiler_47.dll" fi w_override_dlls native d3dcompiler_47 @@ -9848,23 +9852,23 @@ load_dotnetdesktop8() w_metadata dotnet9 dlls \ title="MS .NET Runtime 9.0 LTS" \ publisher="Microsoft" \ - year="2024" \ + year="2026" \ media="download" \ - file1="dotnet-runtime-9.0.7-win-x86.exe" \ + file1="dotnet-runtime-9.0.14-win-x86.exe" \ installed_file1="${W_PROGRAMS_WIN}/dotnet/dotnet.exe" load_dotnet9() { # Official version, see https://dotnet.microsoft.com/en-us/download/dotnet/9.0 - w_download https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.7/dotnet-runtime-9.0.7-win-x86.exe a4d077890c9820d9968a3c310973dceeae6ce949f4af3dae50611c0457196c82 + w_download https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.14/dotnet-runtime-9.0.14-win-x86.exe a99eb555e90eaa6703efa20f3b8fe676a9ba24bd24d439f016c20815d4ff815c w_try_cd "${W_CACHE}"/"${W_PACKAGE}" w_try "${WINE}" "${file1}" ${W_OPT_UNATTENDED:+/quiet} if [ "${W_ARCH}" = "win64" ]; then # Also install the 64-bit version - w_download https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.7/dotnet-runtime-9.0.7-win-x64.exe 482a02fa01dd822d67d5286049ed7cc74fe8eda01848612cd070e0b0583de9c4 - w_try "${WINE}" "dotnet-runtime-9.0.7-win-x64.exe" ${W_OPT_UNATTENDED:+/quiet} + w_download https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.14/dotnet-runtime-9.0.14-win-x64.exe 690f85d3592edb3e5810f8cd8b4a630bbaa2a8abc354061d44d7d6c0f0d8a0dd + w_try "${WINE}" "dotnet-runtime-9.0.14-win-x64.exe" ${W_OPT_UNATTENDED:+/quiet} fi } @@ -11772,17 +11776,24 @@ load_ogg() w_metadata ole32 dlls \ title="MS ole32 Module (ole32.dll)" \ publisher="Microsoft" \ - year="2004" \ + year="2015" \ media="download" \ - file1="../winxpsp3/WindowsXP-KB936929-SP3-x86-ENU.exe" \ + file1="windowsserver2003-kb3072633-x64-enu_e3bee4ea9cab584b77067f26e4aeed5428436327.exe" \ installed_file1="${W_SYSTEM32_DLLS_WIN}/ole32.dll" load_ole32() { # Some applications need this, for example Wechat. - helper_winxpsp3 i386/ole32.dl_ - w_try_cabextract --directory="${W_SYSTEM32_DLLS}" "${W_TMP}"/i386/ole32.dl_ - w_override_dlls native,builtin ole32 + w_download https://catalog.s.download.windowsupdate.com/d/msdownload/update/software/secu/2015/06/windowsserver2003-kb3072633-x64-enu_e3bee4ea9cab584b77067f26e4aeed5428436327.exe 1e0d4065f34bebd5bde679779ec7472cd7e0a285bc7209a50aae744d4849cbdf + w_try_cabextract --directory="${W_TMP}" "${W_CACHE}/${W_PACKAGE}/${file1}" + + w_try_cp_dll "${W_TMP}"/SP2QFE/wow/wole32.dll "${W_SYSTEM32_DLLS}"/ole32.dll + + if [ "${W_ARCH}" = "win64" ]; then + w_try_cp_dll "${W_TMP}"/SP2QFE/ole32.dll "${W_SYSTEM64_DLLS}"/ole32.dll + fi + + w_override_dlls native ole32 } #---------------------------------------------------------------- @@ -12916,32 +12927,17 @@ load_vb5run() #---------------------------------------------------------------- w_metadata vb6run dlls \ - title="MS Visual Basic 6 runtime sp6" \ + title="MS Visual Basic 6 runtime" \ publisher="Microsoft" \ - year="2004" \ + year="2008" \ media="download" \ - file1="vbrun60sp6.exe" \ + file1="../winxpsp3/WindowsXP-KB936929-SP3-x86-ENU.exe" \ installed_file1="${W_SYSTEM32_DLLS_WIN}/msvbvm60.dll" load_vb6run() { - # https://support.microsoft.com/kb/290887 - if test ! -f "${W_CACHE}"/vb6run/vbrun60sp6.exe; then - w_download https://web.archive.org/web/20070204154430/https://download.microsoft.com/download/5/a/d/5ad868a0-8ecd-4bb0-a882-fe53eb7ef348/VB6.0-KB290887-X86.exe 467b5a10c369865f2021d379fc0933cb382146b702bbca4bcb703fc86f4322bb - - w_try "${WINE}" "${W_CACHE}"/vb6run/VB6.0-KB290887-X86.exe "/T:${W_TMP_WIN}" /c ${W_OPT_UNATTENDED:+/q} - if test ! -f "${W_TMP}"/vbrun60sp6.exe; then - w_die vbrun60sp6.exe not found - fi - w_try mv "${W_TMP}"/vbrun60sp6.exe "${W_CACHE}"/vb6run - fi - - # extract the files instead of using installer to avoid https://github.com/Winetricks/winetricks/issues/1806 - w_try_cabextract -L "${W_CACHE}/${W_PACKAGE}/${file1}" -d "${W_TMP}" - - for dll in asycfilt.dll comcat.dll msvbvm60.dll oleaut32.dll olepro32.dll stdole2.tlb; do - w_try mv "${W_TMP}/${dll}" "${W_SYSTEM32_DLLS}" - done + helper_winxpsp3 i386/msvbvm60.dl_ + w_try_cabextract --directory="${W_SYSTEM32_DLLS}" "${W_TMP}"/i386/msvbvm60.dl_ } #---------------------------------------------------------------- @@ -19051,6 +19047,33 @@ load_sandbox() w_call isolate_home } +#---------------------------------------------------------------- + +w_metadata showdotfiles=y settings \ + title="Show dotfiles/folders (.foo) in Windows programs" +w_metadata showdotfiles=n settings \ + title="Hide dotfiles/folders (.foo) in Windows programs (default)" + +load_showdotfiles() +{ + case "$1" in + y|Y) arg="Y";; + n|N) arg="N";; + *) w_die "illegal value $1 for ShowDotFiles";; + esac + + echo "Setting ShowDotFiles to ${arg}" + cat > "${W_TMP}/${W_PACKAGE}.reg" <<_EOF_ +REGEDIT4 + +[HKEY_CURRENT_USER\\Software\\Wine] +"ShowDotFiles"="${arg}" + +_EOF_ + w_try_regedit "${W_TMP}/${W_PACKAGE}.reg" +} + + #### # settings->sound @@ -19855,7 +19878,7 @@ if ! test "${WINETRICKS_LIB}"; then case "$1" in die) w_die "we who are about to die salute you." ;; "") - if [ -z "${DISPLAY}" ]; then + if [ -z "${DISPLAY}" ] && [ -z "${WAYLAND_DISPLAY}" ]; then if [ "$(uname -s)" = "Darwin" ]; then echo "Running on OSX, but DISPLAY is not set...probably using Mac Driver." else