diff --git a/CHANGELOG.md b/CHANGELOG.md index 6817690..d73f617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Jackify Changelog +## v0.5.0 - Non-Premium Support, Modlist Update Handling and Overall Reliability Improvements +**Release Date:** 13/03/26 + +### New in v0.5.0 +- Full non-premium install support in both GUI and CLI. Feedback is welcome on this new feature, both positive and negative +- New Jackify Download Manager for Non-Premium accounts, or files Jackify cannot auto-download. +- Improved modlist update handling so existing installs are detected more reliably and Jackify can reuse the existing setup instead of creating duplicate Steam shortcuts. +- Improved Viva New Vegas automation across GUI and CLI paths. +- Improved Wabbajack and Mod Organizer 2 standalone installation workflows. +- Better guidance when Skyrim AE/CC content is missing. +- Further improvements on user-facing logging and error handling + +### Manual Download Improvements +- Handles manual downloads more smoothly from start to finish: + - opens required links for you in your system Browser + - watches your download folder + - verifies files and moves them to the correct location automatically,continues with the rest of the modlist install when ready +- Better controls in both GUI and CLI: + - pause/resume download flows, or defer individual archives (useful if one is temporarily unavailable) + - retry deferred items + - reopen file links + - change concurrent browser tab count + - change watch folder +- Deferred items (e.g temporarily unavailable) are retried correctly on later retry/recheck passes. + +### Update and Install Reliability +- Worked to improve feature parity between the GUI and CLI frontends, tidying up a few edge cases where CLI behavior did not yet match GUI workflows closely enough. +- Improved update messaging (clearer wording on success/failure). +- Better cancellation handling so stopping a workflow is less likely to leave background processes running. +- Better focus recovery after Steam restart in key workflows. +- Better handling when both Flatpak and native Steam are installed: Jackify now prefers the Steam install that actually contains your installed games, with safe fallback rules if both look valid. +- Install Proton selection now self-heals on startup if the configured Proton was removed, automatically falling back to the best available installed Proton. +- For `/var/home`-based installs (for example Bazzite layouts), ModOrganizer.ini path basis is now aligned so executable/working/game paths resolve correctly. + +### Nexus Authentication +- OAuth protocol handler desktop file is now updated if the registered AppImage path no longer matches the current location, preventing silent callback failures after the AppImage is moved or renamed. +- OAuth waiting dialog now includes a "Paste callback URL" button for manual fallback if the browser does not dispatch the jackify:// callback automatically. + +### Logging and Error Quality +- Better targeted guidance when required prerequisites or content are missing. +- Improved logging around updater source selection (Nexus/GitHub fallback behavior). +- Better error context while keeping sensitive tokens/keys redacted. +- Install failure fallback now surfaces recent actionable engine output (and resource-limit warnings) instead of only a generic exit-code message. + +### Updated jackify-engine to 0.5.0: +- Improved non-premium/manual-download support through a structured manual-download protocol that lets Jackify pause, guide the user, recheck files, and continue installation cleanly once required archives are present. +- Better pre-flight validation before large downloads begin, including earlier checks for game availability, disk space, and filesystem path-length limits. +- More accurate structured error handling for installation failures, with better classification of storage, permission, network, authentication, and validation issues. + +--- + ## v0.4.0 - Error Handling Rewrite **Release Date:** 2026-02-25 diff --git a/README.md b/README.md index 1cba679..2464210 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Jackify is a Linux application for installing and configuring Wabbajack modlists - Non-Premium users can still install modlists via Wabbajack under Proton - Native non-premium support planned for a future release - See the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide) for full details on the options available -- **FUSE** (required for AppImage execution, pre-installed on most distributions) +- **FUSE2 compatibility (libfuse.so.2) is required for AppImage execution** - **Ubuntu/Debian-based distros only** (Ubuntu, Kubuntu, Linux Mint, Pop!_OS, Zorin OS, elementary OS, and others): Qt platform plugin library - `sudo apt install libxcb-cursor-dev` diff --git a/assets/images/wiki/ModlistGuides/AdditionalTools/protonplus-ge-install.png b/assets/images/wiki/UserGuide/AdditionalTools/protonplus-ge-install.png similarity index 100% rename from assets/images/wiki/ModlistGuides/AdditionalTools/protonplus-ge-install.png rename to assets/images/wiki/UserGuide/AdditionalTools/protonplus-ge-install.png diff --git a/assets/images/wiki/ModlistGuides/AdditionalTools/protonplus-main.png b/assets/images/wiki/UserGuide/AdditionalTools/protonplus-main.png similarity index 100% rename from assets/images/wiki/ModlistGuides/AdditionalTools/protonplus-main.png rename to assets/images/wiki/UserGuide/AdditionalTools/protonplus-main.png diff --git a/assets/images/wiki/ModlistGuides/AdditionalTools/protonupqt-add-version.png b/assets/images/wiki/UserGuide/AdditionalTools/protonupqt-add-version.png similarity index 100% rename from assets/images/wiki/ModlistGuides/AdditionalTools/protonupqt-add-version.png rename to assets/images/wiki/UserGuide/AdditionalTools/protonupqt-add-version.png diff --git a/assets/images/wiki/ModlistGuides/AdditionalTools/protonupqt-main.png b/assets/images/wiki/UserGuide/AdditionalTools/protonupqt-main.png similarity index 100% rename from assets/images/wiki/ModlistGuides/AdditionalTools/protonupqt-main.png rename to assets/images/wiki/UserGuide/AdditionalTools/protonupqt-main.png diff --git a/assets/images/wiki/ModlistGuides/AdditionalTools/steam-compatibility-ge-proton.png b/assets/images/wiki/UserGuide/AdditionalTools/steam-compatibility-ge-proton.png similarity index 100% rename from assets/images/wiki/ModlistGuides/AdditionalTools/steam-compatibility-ge-proton.png rename to assets/images/wiki/UserGuide/AdditionalTools/steam-compatibility-ge-proton.png diff --git a/assets/images/wiki/ModlistGuides/ConfigureExisting/jackify-configure-existing-blank.png b/assets/images/wiki/UserGuide/ConfigureExisting/jackify-configure-existing-blank.png similarity index 100% rename from assets/images/wiki/ModlistGuides/ConfigureExisting/jackify-configure-existing-blank.png rename to assets/images/wiki/UserGuide/ConfigureExisting/jackify-configure-existing-blank.png diff --git a/assets/images/wiki/ModlistGuides/ConfigureExisting/jackify-configure-existing-select-modlist.png b/assets/images/wiki/UserGuide/ConfigureExisting/jackify-configure-existing-select-modlist.png similarity index 100% rename from assets/images/wiki/ModlistGuides/ConfigureExisting/jackify-configure-existing-select-modlist.png rename to assets/images/wiki/UserGuide/ConfigureExisting/jackify-configure-existing-select-modlist.png diff --git a/assets/images/wiki/ModlistGuides/ConfigureExisting/jackify-modlist-tasks-configure-existing.png b/assets/images/wiki/UserGuide/ConfigureExisting/jackify-modlist-tasks-configure-existing.png similarity index 100% rename from assets/images/wiki/ModlistGuides/ConfigureExisting/jackify-modlist-tasks-configure-existing.png rename to assets/images/wiki/UserGuide/ConfigureExisting/jackify-modlist-tasks-configure-existing.png diff --git a/assets/images/wiki/ModlistGuides/ConfigureNew/jackify-configure-new-blank.png b/assets/images/wiki/UserGuide/ConfigureNew/jackify-configure-new-blank.png similarity index 100% rename from assets/images/wiki/ModlistGuides/ConfigureNew/jackify-configure-new-blank.png rename to assets/images/wiki/UserGuide/ConfigureNew/jackify-configure-new-blank.png diff --git a/assets/images/wiki/ModlistGuides/ConfigureNew/jackify-configure-new-browse-mo2.png b/assets/images/wiki/UserGuide/ConfigureNew/jackify-configure-new-browse-mo2.png similarity index 100% rename from assets/images/wiki/ModlistGuides/ConfigureNew/jackify-configure-new-browse-mo2.png rename to assets/images/wiki/UserGuide/ConfigureNew/jackify-configure-new-browse-mo2.png diff --git a/assets/images/wiki/ModlistGuides/ConfigureNew/jackify-configure-new-filled.png b/assets/images/wiki/UserGuide/ConfigureNew/jackify-configure-new-filled.png similarity index 100% rename from assets/images/wiki/ModlistGuides/ConfigureNew/jackify-configure-new-filled.png rename to assets/images/wiki/UserGuide/ConfigureNew/jackify-configure-new-filled.png diff --git a/assets/images/wiki/ModlistGuides/shared/AddNonSteamGame.png b/assets/images/wiki/UserGuide/shared/AddNonSteamGame.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/AddNonSteamGame.png rename to assets/images/wiki/UserGuide/shared/AddNonSteamGame.png diff --git a/assets/images/wiki/ModlistGuides/shared/ClearRootBuilder.png b/assets/images/wiki/UserGuide/shared/ClearRootBuilder.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/ClearRootBuilder.png rename to assets/images/wiki/UserGuide/shared/ClearRootBuilder.png diff --git a/assets/images/wiki/ModlistGuides/shared/Jackify_Github_Banner.png b/assets/images/wiki/UserGuide/shared/Jackify_Github_Banner.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Jackify_Github_Banner.png rename to assets/images/wiki/UserGuide/shared/Jackify_Github_Banner.png diff --git a/assets/images/wiki/ModlistGuides/shared/MO2DownloadsError.png b/assets/images/wiki/UserGuide/shared/MO2DownloadsError.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/MO2DownloadsError.png rename to assets/images/wiki/UserGuide/shared/MO2DownloadsError.png diff --git a/assets/images/wiki/ModlistGuides/shared/MO2RegisterNXMLinks.png b/assets/images/wiki/UserGuide/shared/MO2RegisterNXMLinks.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/MO2RegisterNXMLinks.png rename to assets/images/wiki/UserGuide/shared/MO2RegisterNXMLinks.png diff --git a/assets/images/wiki/ModlistGuides/shared/MO2_Executables_Cog.png b/assets/images/wiki/UserGuide/shared/MO2_Executables_Cog.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/MO2_Executables_Cog.png rename to assets/images/wiki/UserGuide/shared/MO2_Executables_Cog.png diff --git a/assets/images/wiki/ModlistGuides/shared/ProtonTricks_GUI_winecfg.png b/assets/images/wiki/UserGuide/shared/ProtonTricks_GUI_winecfg.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/ProtonTricks_GUI_winecfg.png rename to assets/images/wiki/UserGuide/shared/ProtonTricks_GUI_winecfg.png diff --git a/assets/images/wiki/ModlistGuides/shared/ProtonTricks_wincfg_select_default.png b/assets/images/wiki/UserGuide/shared/ProtonTricks_wincfg_select_default.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/ProtonTricks_wincfg_select_default.png rename to assets/images/wiki/UserGuide/shared/ProtonTricks_wincfg_select_default.png diff --git a/assets/images/wiki/ModlistGuides/shared/ProtontricksDiscover.png b/assets/images/wiki/UserGuide/shared/ProtontricksDiscover.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/ProtontricksDiscover.png rename to assets/images/wiki/UserGuide/shared/ProtontricksDiscover.png diff --git a/assets/images/wiki/ModlistGuides/shared/ProtontricksDiscoverInstall.png b/assets/images/wiki/UserGuide/shared/ProtontricksDiscoverInstall.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/ProtontricksDiscoverInstall.png rename to assets/images/wiki/UserGuide/shared/ProtontricksDiscoverInstall.png diff --git a/assets/images/wiki/ModlistGuides/shared/Protontricks_GUI-dotfiles.png b/assets/images/wiki/UserGuide/shared/Protontricks_GUI-dotfiles.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Protontricks_GUI-dotfiles.png rename to assets/images/wiki/UserGuide/shared/Protontricks_GUI-dotfiles.png diff --git a/assets/images/wiki/ModlistGuides/shared/ReEnableENBMods.png b/assets/images/wiki/UserGuide/shared/ReEnableENBMods.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/ReEnableENBMods.png rename to assets/images/wiki/UserGuide/shared/ReEnableENBMods.png diff --git a/assets/images/wiki/ModlistGuides/shared/SSEDisplayEditINI.png b/assets/images/wiki/UserGuide/shared/SSEDisplayEditINI.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/SSEDisplayEditINI.png rename to assets/images/wiki/UserGuide/shared/SSEDisplayEditINI.png diff --git a/assets/images/wiki/ModlistGuides/shared/SSEDisplayEditResolution.png b/assets/images/wiki/UserGuide/shared/SSEDisplayEditResolution.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/SSEDisplayEditResolution.png rename to assets/images/wiki/UserGuide/shared/SSEDisplayEditResolution.png diff --git a/assets/images/wiki/ModlistGuides/shared/STEAM_COMPAT_MOUNTS.png b/assets/images/wiki/UserGuide/shared/STEAM_COMPAT_MOUNTS.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/STEAM_COMPAT_MOUNTS.png rename to assets/images/wiki/UserGuide/shared/STEAM_COMPAT_MOUNTS.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-AddMO2NonSteamGame.png b/assets/images/wiki/UserGuide/shared/Shared-AddMO2NonSteamGame.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-AddMO2NonSteamGame.png rename to assets/images/wiki/UserGuide/shared/Shared-AddMO2NonSteamGame.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-AddNonSteamGame.png b/assets/images/wiki/UserGuide/shared/Shared-AddNonSteamGame.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-AddNonSteamGame.png rename to assets/images/wiki/UserGuide/shared/Shared-AddNonSteamGame.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-BrowseNonSteamGame.png b/assets/images/wiki/UserGuide/shared/Shared-BrowseNonSteamGame.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-BrowseNonSteamGame.png rename to assets/images/wiki/UserGuide/shared/Shared-BrowseNonSteamGame.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-MO2DownloadsError.png b/assets/images/wiki/UserGuide/shared/Shared-MO2DownloadsError.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-MO2DownloadsError.png rename to assets/images/wiki/UserGuide/shared/Shared-MO2DownloadsError.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-MO2ExecutablesCog.png b/assets/images/wiki/UserGuide/shared/Shared-MO2ExecutablesCog.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-MO2ExecutablesCog.png rename to assets/images/wiki/UserGuide/shared/Shared-MO2ExecutablesCog.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-MO2PortableError.png b/assets/images/wiki/UserGuide/shared/Shared-MO2PortableError.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-MO2PortableError.png rename to assets/images/wiki/UserGuide/shared/Shared-MO2PortableError.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-MO2RegisterNXMLinks.png b/assets/images/wiki/UserGuide/shared/Shared-MO2RegisterNXMLinks.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-MO2RegisterNXMLinks.png rename to assets/images/wiki/UserGuide/shared/Shared-MO2RegisterNXMLinks.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-ProtonNonSteamGame.png b/assets/images/wiki/UserGuide/shared/Shared-ProtonNonSteamGame.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-ProtonNonSteamGame.png rename to assets/images/wiki/UserGuide/shared/Shared-ProtonNonSteamGame.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-ProtonUpQT-AddVersion.png b/assets/images/wiki/UserGuide/shared/Shared-ProtonUpQT-AddVersion.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-ProtonUpQT-AddVersion.png rename to assets/images/wiki/UserGuide/shared/Shared-ProtonUpQT-AddVersion.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-ProtonUpQT-InstallVersion.png b/assets/images/wiki/UserGuide/shared/Shared-ProtonUpQT-InstallVersion.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-ProtonUpQT-InstallVersion.png rename to assets/images/wiki/UserGuide/shared/Shared-ProtonUpQT-InstallVersion.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-ProtonUpQTDiscoverSearch.png b/assets/images/wiki/UserGuide/shared/Shared-ProtonUpQTDiscoverSearch.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-ProtonUpQTDiscoverSearch.png rename to assets/images/wiki/UserGuide/shared/Shared-ProtonUpQTDiscoverSearch.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-ProtontricksDiscover.png b/assets/images/wiki/UserGuide/shared/Shared-ProtontricksDiscover.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-ProtontricksDiscover.png rename to assets/images/wiki/UserGuide/shared/Shared-ProtontricksDiscover.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-ProtontricksDiscoverInstall.png b/assets/images/wiki/UserGuide/shared/Shared-ProtontricksDiscoverInstall.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-ProtontricksDiscoverInstall.png rename to assets/images/wiki/UserGuide/shared/Shared-ProtontricksDiscoverInstall.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-STEAM_COMPAT_MOUNTS.png b/assets/images/wiki/UserGuide/shared/Shared-STEAM_COMPAT_MOUNTS.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-STEAM_COMPAT_MOUNTS.png rename to assets/images/wiki/UserGuide/shared/Shared-STEAM_COMPAT_MOUNTS.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-VCRedistInstallComplete.png b/assets/images/wiki/UserGuide/shared/Shared-VCRedistInstallComplete.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-VCRedistInstallComplete.png rename to assets/images/wiki/UserGuide/shared/Shared-VCRedistInstallComplete.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-VCRedistInstallStart.png b/assets/images/wiki/UserGuide/shared/Shared-VCRedistInstallStart.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-VCRedistInstallStart.png rename to assets/images/wiki/UserGuide/shared/Shared-VCRedistInstallStart.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared-WineShell.png b/assets/images/wiki/UserGuide/shared/Shared-WineShell.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared-WineShell.png rename to assets/images/wiki/UserGuide/shared/Shared-WineShell.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared/Jackify_Github_Banner.png b/assets/images/wiki/UserGuide/shared/Shared/Jackify_Github_Banner.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared/Jackify_Github_Banner.png rename to assets/images/wiki/UserGuide/shared/Shared/Jackify_Github_Banner.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared/mo2-run-button.png b/assets/images/wiki/UserGuide/shared/Shared/mo2-run-button.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared/mo2-run-button.png rename to assets/images/wiki/UserGuide/shared/Shared/mo2-run-button.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared/nexus-jackify-download-page.png b/assets/images/wiki/UserGuide/shared/Shared/nexus-jackify-download-page.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared/nexus-jackify-download-page.png rename to assets/images/wiki/UserGuide/shared/Shared/nexus-jackify-download-page.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared/steam-library-tuxborn-premium.png b/assets/images/wiki/UserGuide/shared/Shared/steam-library-tuxborn-premium.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared/steam-library-tuxborn-premium.png rename to assets/images/wiki/UserGuide/shared/Shared/steam-library-tuxborn-premium.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared_ProtonUp-AddVersion.png b/assets/images/wiki/UserGuide/shared/Shared_ProtonUp-AddVersion.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared_ProtonUp-AddVersion.png rename to assets/images/wiki/UserGuide/shared/Shared_ProtonUp-AddVersion.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared_ProtonUp-QTSearch.png b/assets/images/wiki/UserGuide/shared/Shared_ProtonUp-QTSearch.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared_ProtonUp-QTSearch.png rename to assets/images/wiki/UserGuide/shared/Shared_ProtonUp-QTSearch.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared_ProtonUp-QTsteamtinkerlaunch.png b/assets/images/wiki/UserGuide/shared/Shared_ProtonUp-QTsteamtinkerlaunch.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared_ProtonUp-QTsteamtinkerlaunch.png rename to assets/images/wiki/UserGuide/shared/Shared_ProtonUp-QTsteamtinkerlaunch.png diff --git a/assets/images/wiki/ModlistGuides/shared/Shared_STL-CustomCommand.png b/assets/images/wiki/UserGuide/shared/Shared_STL-CustomCommand.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/Shared_STL-CustomCommand.png rename to assets/images/wiki/UserGuide/shared/Shared_STL-CustomCommand.png diff --git a/assets/images/wiki/ModlistGuides/shared/VCRedistInstallComplete.png b/assets/images/wiki/UserGuide/shared/VCRedistInstallComplete.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/VCRedistInstallComplete.png rename to assets/images/wiki/UserGuide/shared/VCRedistInstallComplete.png diff --git a/assets/images/wiki/ModlistGuides/shared/VCRedistInstallStart.png b/assets/images/wiki/UserGuide/shared/VCRedistInstallStart.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/VCRedistInstallStart.png rename to assets/images/wiki/UserGuide/shared/VCRedistInstallStart.png diff --git a/assets/images/wiki/ModlistGuides/shared/WineShell.png b/assets/images/wiki/UserGuide/shared/WineShell.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/WineShell.png rename to assets/images/wiki/UserGuide/shared/WineShell.png diff --git a/assets/images/wiki/ModlistGuides/shared/mo2-run-button.png b/assets/images/wiki/UserGuide/shared/mo2-run-button.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/mo2-run-button.png rename to assets/images/wiki/UserGuide/shared/mo2-run-button.png diff --git a/assets/images/wiki/ModlistGuides/shared/nexus-jackify-download-page.png b/assets/images/wiki/UserGuide/shared/nexus-jackify-download-page.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/nexus-jackify-download-page.png rename to assets/images/wiki/UserGuide/shared/nexus-jackify-download-page.png diff --git a/assets/images/wiki/ModlistGuides/shared/start.png b/assets/images/wiki/UserGuide/shared/start.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/start.png rename to assets/images/wiki/UserGuide/shared/start.png diff --git a/assets/images/wiki/ModlistGuides/shared/steam-library-tuxborn-premium.png b/assets/images/wiki/UserGuide/shared/steam-library-tuxborn-premium.png similarity index 100% rename from assets/images/wiki/ModlistGuides/shared/steam-library-tuxborn-premium.png rename to assets/images/wiki/UserGuide/shared/steam-library-tuxborn-premium.png diff --git a/jackify/__init__.py b/jackify/__init__.py index 9bc7204..523edbc 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.4.0" +__version__ = "0.5.0" diff --git a/jackify/backend/core/modlist_operations.py b/jackify/backend/core/modlist_operations.py index 1d6b350..e6de2c0 100644 --- a/jackify/backend/core/modlist_operations.py +++ b/jackify/backend/core/modlist_operations.py @@ -107,7 +107,7 @@ def get_jackify_engine_path(): logger.warning(f"AppImage engine not found at expected path: {engine_path}") # Priority 3: Check if THIS process is actually running from Jackify AppImage - # (not just inheriting APPDIR from another AppImage like Cursor) + # (not just inheriting APPDIR from another AppImage context) appdir = os.environ.get('APPDIR') if appdir and sys.argv[0] and 'jackify' in sys.argv[0].lower() and '/tmp/.mount_' in sys.argv[0]: # Only use AppImage path if we're actually running a Jackify AppImage @@ -179,6 +179,92 @@ class ModlistInstallCLI( # Initialize process tracking for cleanup self._current_process = None + @staticmethod + def _normalize_version_token(value: str | None) -> str | None: + if value is None: + return None + token = str(value).strip() + if not token: + return None + return token.lstrip("vV").lower() + + @staticmethod + def _normalize_modlist_name(value: str | None) -> str: + return " ".join((value or "").strip().lower().split()) + + def _get_requested_modlist_version(self) -> str | None: + info = self.context.get("selected_modlist_info") or {} + return self._normalize_version_token(info.get("version")) + + def _evaluate_update_candidate( + self, + modlist_name: str, + install_dir: str, + existing_appid: str | None, + ) -> tuple[bool, dict]: + from jackify.backend.utils.modlist_meta import read_modlist_meta + + result = { + "eligible": False, + "reason": "unknown", + "requested_version": None, + "installed_version": None, + "version_relation": "unknown", + "installed_name": None, + } + if not existing_appid: + result["reason"] = "missing_shortcut_appid" + return False, result + + meta = read_modlist_meta(install_dir) + if not meta: + result["reason"] = "missing_meta" + return False, result + + installed_name = (meta.get("modlist_name") or "").strip() + result["installed_name"] = installed_name + if self._normalize_modlist_name(installed_name) != self._normalize_modlist_name(modlist_name): + result["reason"] = "modlist_name_mismatch" + return False, result + + requested_version = self._get_requested_modlist_version() + installed_version = self._normalize_version_token(meta.get("modlist_version")) + result["requested_version"] = requested_version + result["installed_version"] = installed_version + if requested_version and installed_version: + result["version_relation"] = "same" if requested_version == installed_version else "different" + + result["eligible"] = True + result["reason"] = "eligible" + return True, result + + def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None: + try: + install_real = os.path.realpath(install_dir) + candidate_exes = [ + os.path.join(install_real, "ModOrganizer.exe"), + os.path.join(install_real, "files", "ModOrganizer.exe"), + ] + + for exe_path in candidate_exes: + if not os.path.exists(exe_path): + continue + appid = self.shortcut_handler.get_appid_from_vdf(modlist_name, exe_path) + if appid: + return appid + + for shortcut in self.shortcut_handler.find_shortcuts_by_exe("ModOrganizer.exe"): + if ( + shortcut.get("AppName", "").strip() == modlist_name.strip() + and os.path.realpath(shortcut.get("StartDir", "")) == install_real + ): + raw_appid = shortcut.get("appid") + if raw_appid is not None: + return str(int(raw_appid) & 0xFFFFFFFF) + except Exception as e: + self.logger.warning("CLI update detection: failed shortcut lookup: %s", e) + return None + def cleanup(self): """Clean up any running jackify-engine process""" if self._current_process and self._current_process.poll() is None: diff --git a/jackify/backend/core/modlist_operations_configuration_cli.py b/jackify/backend/core/modlist_operations_configuration_cli.py index dc845de..204e989 100644 --- a/jackify/backend/core/modlist_operations_configuration_cli.py +++ b/jackify/backend/core/modlist_operations_configuration_cli.py @@ -1,4 +1,5 @@ """CLI configuration phase methods for ModlistInstallCLI (Mixin).""" +import json import logging import os import subprocess @@ -166,19 +167,81 @@ class ModlistOperationsConfigurationCLIMixin: from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env clean_env = get_clean_subprocess_env() - self._current_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir) + self._current_process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=False, + env=clean_env, + cwd=engine_dir, + ) proc = self._current_process + def _write_stdin(payload: str) -> bool: + if not proc.stdin or proc.poll() is not None: + return False + try: + proc.stdin.write((payload + '\n').encode('utf-8')) + proc.stdin.flush() + return True + except Exception: + self.logger.debug("Failed writing to engine stdin", exc_info=True) + return False + buffer = b'' inline_progress_active = False + pending_manual = [] while True: chunk = proc.stdout.read(1) if not chunk: break buffer += chunk - if chunk == b'\n': + if chunk in (b'\n', b'\r'): line = buffer.decode('utf-8', errors='replace') + decoded = line.rstrip('\r\n') + if decoded.startswith('{'): + try: + event = json.loads(decoded) + except (json.JSONDecodeError, ValueError): + event = None + if event: + event_name = event.get('event') + if event_name == 'manual_download_required': + pending_manual.append(event) + buffer = b'' + continue + if event_name == 'manual_download_list_complete': + loop_iter = event.get('loop_iteration', 1) + for item in pending_manual: + item['loop_iteration'] = loop_iter + from jackify.backend.handlers.config_handler import ConfigHandler + raw_limit = ConfigHandler().get('manual_download_concurrent_limit', 2) + try: + manual_limit = int(raw_limit) + except (TypeError, ValueError): + manual_limit = 2 + from jackify.frontends.cli.commands.manual_download_flow import run_cli_manual_download_phase + completed = run_cli_manual_download_phase( + events=list(pending_manual), + loop_iteration=loop_iter, + download_dir=actual_download_path, + stdin_write=_write_stdin, + concurrent_limit=max(1, min(5, manual_limit)), + ) + if not completed: + if proc.poll() is None: + proc.terminate() + buffer = b'' + break + pending_manual.clear() + buffer = b'' + continue + if event_name == 'manual_download_phase_complete': + print("All manual downloads confirmed. Resuming installation...") + buffer = b'' + continue if '[FILE_PROGRESS]' in line: parts = line.split('[FILE_PROGRESS]', 1) if parts[0].strip(): @@ -197,26 +260,6 @@ class ModlistOperationsConfigurationCLIMixin: inline_progress_active = False print(line, end='') buffer = b'' - elif chunk == b'\r': - line = buffer.decode('utf-8', errors='replace') - if '[FILE_PROGRESS]' in line: - parts = line.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - line = parts[0].rstrip() - else: - buffer = b'' - continue - clean_line = line.rstrip('\r\n') - if clean_line.startswith("Installing files "): - print(f"\r{clean_line}", end='') - inline_progress_active = True - else: - if inline_progress_active: - print() - inline_progress_active = False - print(line, end='') - sys.stdout.flush() - buffer = b'' if buffer: line = buffer.decode('utf-8', errors='replace') @@ -400,6 +443,16 @@ class ModlistOperationsConfigurationCLIMixin: app_id = None use_automated_prefix = os.environ.get('JACKIFY_USE_AUTOMATED_PREFIX', '1') == '1' + existing_shortcut_appid = self.context.get('existing_shortcut_appid') + update_existing_install = bool(self.context.get('update_existing_install')) + + if update_existing_install and existing_shortcut_appid: + app_id = str(existing_shortcut_appid) + success = True + prefix_path = None + result = True + print(f"\n{COLOR_INFO}Update mode selected. Reusing existing Steam shortcut AppID {app_id}.{COLOR_RESET}") + use_automated_prefix = False if use_automated_prefix: print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}") @@ -535,17 +588,20 @@ class ModlistOperationsConfigurationCLIMixin: success, prefix_path, app_id = True, None, None else: success, prefix_path, app_id = False, None, None - - if success: + if success: + 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}") + else: print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}") if prefix_path: print(f"{COLOR_INFO}Proton prefix created at: {prefix_path}{COLOR_RESET}") if app_id: print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}") - else: - print(f"{COLOR_ERROR}Automated Steam setup failed. Result: {result}{COLOR_RESET}") - print(f"{COLOR_ERROR}Steam integration was not completed. Please check the logs for details.{COLOR_RESET}") - return + else: + print(f"{COLOR_ERROR}Automated Steam setup failed. Result: {result}{COLOR_RESET}") + print(f"{COLOR_ERROR}Steam integration was not completed. Please check the logs for details.{COLOR_RESET}") + return from jackify.backend.services.modlist_service import ModlistService from jackify.backend.models.modlist import ModlistContext @@ -572,18 +628,28 @@ class ModlistOperationsConfigurationCLIMixin: progress_callback("") progress_callback("=== Configuration Phase ===") - print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}") - self.logger.info("Running post-installation configuration phase using ModlistService") + print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}") + self.logger.info("Running post-installation configuration phase using ModlistService") configuration_success = modlist_service.configure_modlist_post_steam(modlist_context) if configuration_success: - print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}") self.logger.info("Post-installation configuration completed successfully") + print(f"{COLOR_INFO}Core configuration complete. Checking post-install automation...{COLOR_RESET}") try: # Ensure CLI install flow gets the same VNV automation behavior as GUI. - from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable + from jackify.backend.services.vnv_integration_helper import ( + run_vnv_automation_if_applicable, + should_offer_vnv_automation, + ) from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + from jackify.backend.services.vnv_post_install_service import VNVPostInstallService + from jackify.backend.handlers.path_handler import PathHandler + from jackify.frontends.cli.commands.vnv_manual_downloads import ( + build_vnv_cli_manual_file_callback, + create_vnv_cli_progress_callback, + ensure_vnv_cli_manual_downloads, + ) modlist_name_for_automation = self.context.get('modlist_name') or shortcut_name or "" def _confirm_vnv(description: str) -> bool: @@ -593,31 +659,47 @@ class ModlistOperationsConfigurationCLIMixin: except (EOFError, KeyboardInterrupt): return False return user_input in ("", "y", "yes") - def _manual_vnv_file(title: str, instructions: str): - print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}") - print(instructions) - try: - file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip() - except (EOFError, KeyboardInterrupt): - return None - if not file_input: - return None - selected = Path(file_input).expanduser().resolve() - return selected if selected.exists() else None - automation_ran, vnv_error = run_vnv_automation_if_applicable( - modlist_name=modlist_name_for_automation, - modlist_install_location=Path(install_dir_str), - game_root=None, # Auto-detect from modlist structure. - ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), - progress_callback=lambda msg: print(msg), - manual_file_callback=_manual_vnv_file, - confirmation_callback=_confirm_vnv, - ) - if automation_ran and not vnv_error: - print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}") - if vnv_error: - print(f"{COLOR_WARNING}VNV automation encountered an error: {vnv_error}{COLOR_RESET}") - print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}") + install_path = Path(install_dir_str) + if should_offer_vnv_automation(modlist_name_for_automation, install_path): + game_paths = PathHandler().find_vanilla_game_paths() + resolved_game_root = game_paths.get('Fallout New Vegas') + vnv_service = VNVPostInstallService( + modlist_install_location=install_path, + game_root=resolved_game_root or install_path, + ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), + ) + completed = vnv_service.check_already_completed() + all_vnv_steps_done = ( + completed['root_mods'] + and completed['4gb_patch'] + and completed['bsa_decompressed'] + ) + if all_vnv_steps_done: + print(f"{COLOR_INFO}VNV post-install steps are already complete.{COLOR_RESET}") + elif _confirm_vnv(vnv_service.get_automation_description()): + if not ensure_vnv_cli_manual_downloads(vnv_service, output_callback=print): + print(f"{COLOR_WARNING}VNV manual downloads were not completed. Skipping VNV automation.{COLOR_RESET}") + else: + progress_callback, close_progress = create_vnv_cli_progress_callback(print) + try: + automation_ran, vnv_error = run_vnv_automation_if_applicable( + modlist_name=modlist_name_for_automation, + modlist_install_location=install_path, + game_root=None, # Auto-detect from modlist structure. + ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), + progress_callback=progress_callback, + manual_file_callback=build_vnv_cli_manual_file_callback(vnv_service, output_callback=print), + confirmation_callback=lambda _description: True, + ) + finally: + close_progress() + if automation_ran and not vnv_error: + print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}") + if vnv_error: + print(f"{COLOR_WARNING}VNV automation encountered an error: {vnv_error}{COLOR_RESET}") + print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}") + else: + print(f"{COLOR_INFO}VNV automation skipped by user.{COLOR_RESET}") except Exception as vnv_err: self.logger.error("VNV post-install automation failed: %s", vnv_err, exc_info=True) print(f"{COLOR_WARNING}VNV automation could not be completed. Check logs for details.{COLOR_RESET}") @@ -632,6 +714,7 @@ class ModlistOperationsConfigurationCLIMixin: except Exception as ttw_err: self.logger.error("TTW post-install prompt failed: %s", ttw_err, exc_info=True) print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}") + print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}") else: print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}") self.logger.warning("Post-installation configuration had issues") diff --git a/jackify/backend/core/modlist_operations_configuration_gui.py b/jackify/backend/core/modlist_operations_configuration_gui.py index dd2fb9b..1e45add 100644 --- a/jackify/backend/core/modlist_operations_configuration_gui.py +++ b/jackify/backend/core/modlist_operations_configuration_gui.py @@ -68,7 +68,7 @@ class ModlistOperationsConfigurationGUIMixin: if result: if completion_callback: - completion_callback(True, "Configuration completed successfully!", config_context['name']) + completion_callback(True, "Core configuration complete", config_context['name']) return True else: retry_count += 1 @@ -139,7 +139,7 @@ class ModlistOperationsConfigurationGUIMixin: if result: if completion_callback: - completion_callback(True, "Configuration completed successfully!", config_context['name']) + completion_callback(True, "Core configuration complete", config_context['name']) return True else: if progress_callback: diff --git a/jackify/backend/core/modlist_operations_discovery.py b/jackify/backend/core/modlist_operations_discovery.py index 6a11786..6e9a24b 100644 --- a/jackify/backend/core/modlist_operations_discovery.py +++ b/jackify/backend/core/modlist_operations_discovery.py @@ -243,6 +243,46 @@ class ModlistOperationsDiscoveryMixin: self.context['download_dir'] = download_dir_path self.logger.debug(f"Download directory context set to: {self.context['download_dir']}") + install_dir_value = self.context.get('install_dir') + install_dir_real = os.path.realpath(str(install_dir_value[0] if isinstance(install_dir_value, tuple) else install_dir_value)) + existing_appid = self._find_existing_shortcut_appid(self.context['modlist_name'], install_dir_real) + eligible_update, update_meta = self._evaluate_update_candidate( + self.context['modlist_name'], + install_dir_real, + existing_appid, + ) + if eligible_update: + print("\n" + "-" * 28) + print(f"{COLOR_WARNING}Existing modlist installation detected in this directory.{COLOR_RESET}") + relation = update_meta.get("version_relation") + if relation == "different": + print( + f"{COLOR_INFO}Detected version change: installed v{update_meta.get('installed_version')} -> " + f"selected v{update_meta.get('requested_version')}.{COLOR_RESET}" + ) + elif relation == "same" and update_meta.get("installed_version"): + print( + f"{COLOR_INFO}Detected same version (v{update_meta.get('installed_version')}). " + "Use update mode for repair/reconfigure behavior." + f"{COLOR_RESET}" + ) + print("Choose how to proceed:") + print(" 1. Update existing install (recommended)") + print(" 2. New install with a different Steam shortcut name") + print(" 0. Cancel") + update_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip() + if update_choice == "1": + self.context['update_existing_install'] = True + self.context['existing_shortcut_appid'] = existing_appid + self.logger.info("CLI update mode selected; reusing AppID %s", existing_appid) + elif update_choice == "2": + print( + f"{COLOR_WARNING}For a new install, choose a different Modlist Name before proceeding.{COLOR_RESET}" + ) + return None + else: + self.logger.info("User cancelled at CLI update detection prompt.") + return None + if 'nexus_api_key' not in self.context or not self.context.get('nexus_api_key'): from jackify.backend.services.nexus_auth_service import NexusAuthService auth_service = NexusAuthService() diff --git a/jackify/backend/handlers/config_handler.py b/jackify/backend/handlers/config_handler.py index 6968625..a458fe7 100644 --- a/jackify/backend/handlers/config_handler.py +++ b/jackify/backend/handlers/config_handler.py @@ -17,6 +17,10 @@ from typing import Optional from .config_handler_encryption import ConfigEncryptionMixin from .config_handler_directories import ConfigDirectoriesMixin from .config_handler_proton import ConfigProtonMixin +from jackify.shared.steam_utils import ( + STEAM_PREFERENCE_AUTO, + resolve_preferred_steam_installation, +) logger = logging.getLogger(__name__) @@ -50,6 +54,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM "resolution": None, "protontricks_path": None, "steam_path": None, + "steam_install_preference": STEAM_PREFERENCE_AUTO, # auto|flatpak|native "nexus_api_key": None, # Base64 encoded API key "default_install_parent_dir": None, # Parent directory for modlist installations "default_download_parent_dir": None, # Parent directory for downloads @@ -62,6 +67,8 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM "proton_path": None, # Install Proton path (for jackify-engine) - None means auto-detect "proton_version": None, # Install Proton version name - None means auto-detect "steam_restart_strategy": "jackify", # "jackify" (default) or "simple" + "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) } @@ -72,14 +79,13 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM # Perform version migrations self._migrate_config() + # Normalize/repair Proton selections on every startup so stale deleted versions + # cannot break workflows. + self.normalize_proton_paths_on_boot() + # If steam_path is not set, detect it if not self.settings["steam_path"]: self.settings["steam_path"] = self._detect_steam_path() - - # Auto-detect and set Proton version ONLY on first run (config file doesn't exist) - # Do NOT overwrite user's saved settings! - if not os.path.exists(self.config_file) and not self.settings.get("proton_path"): - self._auto_detect_proton() # If jackify_data_dir is not set, initialize it to default if not self.settings.get("jackify_data_dir"): @@ -95,35 +101,16 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM str: Path to the Steam installation or None if not found """ logger.info("Detecting Steam installation path...") - - # Common Steam installation paths - steam_paths = [ - os.path.expanduser("~/.steam/steam"), - os.path.expanduser("~/.local/share/Steam"), - os.path.expanduser("~/.steam/root") - ] - - # Check each path - for path in steam_paths: - if os.path.exists(path): - logger.info(f"Found Steam installation at: {path}") - return path - - # If not found in common locations, try to find using libraryfolders.vdf - libraryfolders_vdf_paths = [ - os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"), - os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"), - os.path.expanduser("~/.steam/root/config/libraryfolders.vdf"), - os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf") # Flatpak - ] - - for vdf_path in libraryfolders_vdf_paths: - if os.path.exists(vdf_path): - # Extract the Steam path from the libraryfolders.vdf path - steam_path = os.path.dirname(os.path.dirname(vdf_path)) - logger.info(f"Found Steam installation at: {steam_path}") - return steam_path - + preference = self.settings.get("steam_install_preference", STEAM_PREFERENCE_AUTO) + install_type, install_root = resolve_preferred_steam_installation(preference=preference) + if install_root: + logger.info( + "Selected Steam installation: %s (%s)", + install_type, + install_root, + ) + return str(install_root) + logger.error("Steam installation not found") return None @@ -376,4 +363,4 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM - \ No newline at end of file + diff --git a/jackify/backend/handlers/config_handler_proton.py b/jackify/backend/handlers/config_handler_proton.py index 872ce38..2e2b1ce 100644 --- a/jackify/backend/handlers/config_handler_proton.py +++ b/jackify/backend/handlers/config_handler_proton.py @@ -3,6 +3,8 @@ Config handler Proton path and version getters and auto-detect. """ import logging +from pathlib import Path +from typing import Optional, Dict, Any logger = logging.getLogger(__name__) @@ -10,6 +12,105 @@ logger = logging.getLogger(__name__) class ConfigProtonMixin: """Mixin providing Proton path/version and auto-detect for ConfigHandler.""" + @staticmethod + def _is_usable_proton_path(proton_path: Optional[str]) -> bool: + """Return True when path looks like a valid Proton install directory.""" + if not proton_path: + return False + try: + p = Path(str(proton_path)).expanduser() + if not p.is_dir(): + return False + # Valve Proton structure + if (p / "dist" / "bin" / "wine").exists(): + return True + # GE-Proton structure + if (p / "files" / "bin" / "wine").exists(): + return True + return False + except Exception: + return False + + @staticmethod + def _best_proton_entry() -> Optional[Dict[str, Any]]: + """Get best detected Proton entry or None.""" + try: + from .wine_utils import WineUtils + return WineUtils.select_best_proton() + except Exception: + return None + + def normalize_proton_paths_on_boot(self) -> bool: + """ + Ensure stored Proton paths are valid at startup, repairing stale selections. + + Rules: + - If install proton path is missing/invalid, auto-detect next best and persist it. + - If no compatible Proton exists, persist install path/version as null. + - If game proton path is set and invalid, reset it to install proton (or null). + + Returns: + True if config values were changed and saved, False otherwise. + """ + changed = False + + install_path = self.settings.get("proton_path") + if install_path == "auto": + install_path = None + + install_valid = self._is_usable_proton_path(install_path) + if not install_valid: + best = self._best_proton_entry() + if best: + best_path = str(best["path"]) + best_name = str(best.get("name") or Path(best_path).name) + if self.settings.get("proton_path") != best_path: + self.settings["proton_path"] = best_path + changed = True + if self.settings.get("proton_version") != best_name: + self.settings["proton_version"] = best_name + changed = True + logger.warning( + "Install Proton path was missing/invalid; auto-selected %s (%s)", + best_name, + best_path, + ) + else: + if self.settings.get("proton_path") is not None: + self.settings["proton_path"] = None + changed = True + if self.settings.get("proton_version") is not None: + self.settings["proton_version"] = None + changed = True + logger.warning( + "Install Proton path was missing/invalid and no compatible Proton was found" + ) + else: + # Keep proton_version in sync with existing valid path when missing/legacy. + if not self.settings.get("proton_version"): + self.settings["proton_version"] = Path(str(install_path)).name + changed = True + + effective_install = self.settings.get("proton_path") + game_path = self.settings.get("game_proton_path") + + # Legacy/placeholder values should not persist for runtime resolution. + if game_path in ("same_as_install", "auto"): + target = effective_install + if self.settings.get("game_proton_path") != target: + self.settings["game_proton_path"] = target + changed = True + elif game_path and not self._is_usable_proton_path(game_path): + self.settings["game_proton_path"] = effective_install + changed = True + logger.warning( + "Game Proton path was missing/invalid; reset to install Proton path" + ) + + if changed: + self.save_config() + return changed + def get_proton_path(self): """Retrieve the saved Install Proton path. Always reads fresh from disk.""" try: diff --git a/jackify/backend/handlers/menu_handler_modlist.py b/jackify/backend/handlers/menu_handler_modlist.py index 125c1a9..9465be2 100644 --- a/jackify/backend/handlers/menu_handler_modlist.py +++ b/jackify/backend/handlers/menu_handler_modlist.py @@ -279,46 +279,56 @@ class ModlistMenuHandler: timestamp = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]" print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}") - # Run the automated workflow - result = prefix_service.run_working_workflow( - modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck - ) - - # Handle the result - if isinstance(result, tuple) and len(result) == 4: - if result[0] == "CONFLICT": - # Handle conflict - ask user what to do - conflicts = result[1] - print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}") - for i, conflict in enumerate(conflicts, 1): - print(f" {i}. Name: {conflict['name']}") - print(f" Executable: {conflict['exe']}") - print(f" Start Directory: {conflict['startdir']}") - print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}") - print(" 1. Use existing shortcut (recommended)") - print(" 2. Create new shortcut anyway") - choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip() - if choice == "1": - # Use existing shortcut - existing_appid = conflicts[0].get('appid') - if existing_appid: - context = { - "name": modlist_name, - "appid": str(existing_appid), - "path": mo2_dir, - "manual_steps_completed": True, - "resolution": None - } - return self.run_modlist_configuration_phase(context) - elif choice == "2": - # Create new shortcut - would need to handle this, but for now just fail - print(f"{COLOR_ERROR}Creating new shortcut with same name not supported in this flow.{COLOR_RESET}") - return True - else: + while True: + result = prefix_service.run_working_workflow( + modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck + ) + + if isinstance(result, tuple) and len(result) == 4: + if result[0] == "CONFLICT": + conflicts = result[1] + print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}") + for i, conflict in enumerate(conflicts, 1): + print(f" {i}. Name: {conflict['name']}") + print(f" Executable: {conflict['exe']}") + print(f" Start Directory: {conflict['startdir']}") + print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}") + print(" 1. Use existing shortcut (recommended)") + print(" 2. Choose a different shortcut name") + choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip() + if choice == "1": + existing_appid = conflicts[0].get('appid') + if existing_appid: + context = { + "name": modlist_name, + "appid": str(existing_appid), + "path": mo2_dir, + "manual_steps_completed": True, + "resolution": None + } + return self.run_modlist_configuration_phase(context) + print(f"{COLOR_ERROR}Could not determine existing shortcut AppID.{COLOR_RESET}") + return True + if choice == "2": + print("") + print(f"{COLOR_PROMPT}Enter a different shortcut name for this modlist.{COLOR_RESET}") + print(f"{COLOR_INFO}(Current conflicting name: {modlist_name}){COLOR_RESET}") + new_name = input(f"{COLOR_PROMPT}New shortcut name (or 'q' to cancel): {COLOR_RESET}").strip() + if new_name.lower() == 'q': + print(f"{COLOR_INFO}Configuration cancelled by user.{COLOR_RESET}") + return True + if not new_name: + print(f"{COLOR_ERROR}Name cannot be empty.{COLOR_RESET}") + continue + if new_name == modlist_name: + print(f"{COLOR_ERROR}Please enter a different name to resolve the conflict.{COLOR_RESET}") + continue + modlist_name = new_name + print(f"{COLOR_INFO}Retrying Steam setup with shortcut name: {modlist_name}{COLOR_RESET}") + continue print(f"{COLOR_ERROR}Invalid choice.{COLOR_RESET}") return True - else: - # Success - get the results + success, prefix_path, appid_int, last_timestamp = result if success and appid_int: context = { @@ -330,10 +340,9 @@ class ModlistMenuHandler: } self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}") return self.run_modlist_configuration_phase(context) - else: - print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}") - return True - else: + print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}") + return True + # Unexpected result format print(f"{COLOR_ERROR}Automated workflow returned unexpected format.{COLOR_RESET}") self.logger.error(f"Unexpected result format from automated workflow: {result}") @@ -566,8 +575,18 @@ class ModlistMenuHandler: # Run modlist-specific post-install automation (e.g., VNV) before showing completion # Only in CLI mode - GUI handles this in install_modlist.py if not gui_mode: - from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable + from jackify.backend.services.vnv_integration_helper import ( + run_vnv_automation_if_applicable, + should_offer_vnv_automation, + ) from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + from jackify.backend.services.vnv_post_install_service import VNVPostInstallService + from jackify.backend.handlers.path_handler import PathHandler + from jackify.frontends.cli.commands.vnv_manual_downloads import ( + build_vnv_cli_manual_file_callback, + create_vnv_cli_progress_callback, + ensure_vnv_cli_manual_downloads, + ) from pathlib import Path modlist_name = context.get('name', '') @@ -581,33 +600,46 @@ class ModlistMenuHandler: except (EOFError, KeyboardInterrupt): return False return user_input in ("", "y", "yes") - - def _manual_vnv_file(title: str, instructions: str): - print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}") - print(instructions) - try: - file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip() - except (EOFError, KeyboardInterrupt): - return None - if not file_input: - return None - selected = Path(file_input).expanduser().resolve() - return selected if selected.exists() else None - - automation_ran, error = run_vnv_automation_if_applicable( - modlist_name=modlist_name, - modlist_install_location=modlist_path, - game_root=None, # Will be auto-detected - ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), - progress_callback=lambda msg: print(msg), - manual_file_callback=_manual_vnv_file, - confirmation_callback=_confirm_vnv - ) - if automation_ran and not error: - print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}") - if error: - print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}") - print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}") + if should_offer_vnv_automation(modlist_name, modlist_path): + game_paths = PathHandler().find_vanilla_game_paths() + resolved_game_root = game_paths.get('Fallout New Vegas') + vnv_service = VNVPostInstallService( + modlist_install_location=modlist_path, + game_root=resolved_game_root or modlist_path, + ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), + ) + completed = vnv_service.check_already_completed() + all_vnv_steps_done = ( + completed['root_mods'] + and completed['4gb_patch'] + and completed['bsa_decompressed'] + ) + if all_vnv_steps_done: + print(f"{COLOR_INFO}VNV post-install steps are already complete.{COLOR_RESET}") + elif _confirm_vnv(vnv_service.get_automation_description()): + if not ensure_vnv_cli_manual_downloads(vnv_service, output_callback=print): + print(f"{COLOR_WARNING}VNV manual downloads were not completed. Skipping VNV automation.{COLOR_RESET}") + else: + progress_callback, close_progress = create_vnv_cli_progress_callback(print) + try: + automation_ran, error = run_vnv_automation_if_applicable( + modlist_name=modlist_name, + modlist_install_location=modlist_path, + game_root=None, # Will be auto-detected + ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), + progress_callback=progress_callback, + manual_file_callback=build_vnv_cli_manual_file_callback(vnv_service, output_callback=print), + confirmation_callback=lambda _description: True, + ) + finally: + close_progress() + if automation_ran and not error: + print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}") + if error: + print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}") + print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}") + else: + print(f"{COLOR_INFO}VNV automation skipped by user.{COLOR_RESET}") except Exception as e: self.logger.debug(f"VNV automation check skipped: {e}") # Not an error - just means VNV automation wasn't applicable diff --git a/jackify/backend/handlers/modlist_configuration.py b/jackify/backend/handlers/modlist_configuration.py index d7beba7..8377ef7 100644 --- a/jackify/backend/handlers/modlist_configuration.py +++ b/jackify/backend/handlers/modlist_configuration.py @@ -401,6 +401,18 @@ class ModlistConfigurationMixin: else: self.logger.warning("Could not set download_directory in ModOrganizer.ini") + # 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 + # engine-installed workflows skip edit_binary_working_paths. + if not self.path_handler.align_home_path_basis( + modlist_ini_path=modlist_ini_path_obj, + modlist_dir_path=modlist_dir_path_obj, + modlist_sdcard=self.modlist_sdcard, + ): + self.logger.error("Failed to align home-path basis in ModOrganizer.ini. Configuration aborted.") + self.logger.error("Failed to align /home path basis in ModOrganizer.ini.") + return False + self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done") # Step 9: Update Resolution Settings (if applicable) @@ -539,6 +551,9 @@ class ModlistConfigurationMixin: else: self.logger.debug("Step 13: No special launch options needed for this modlist type") + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Finalizing post-install configuration") + # Do not call status_callback here, the final message is handled in menu_handler # if status_callback: # status_callback("Configuration completed successfully!") @@ -546,6 +561,8 @@ class ModlistConfigurationMixin: self.logger.info("Configuration steps completed successfully.") # Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333) + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Re-applying final Windows compatibility settings") self._re_enforce_windows_10_mode() return True # Return True on success @@ -581,4 +598,3 @@ class ModlistConfigurationMixin: else: self.selected_resolution = None self.logger.info("Resolution setup skipped by user.") - diff --git a/jackify/backend/handlers/modlist_install_cli_ttw.py b/jackify/backend/handlers/modlist_install_cli_ttw.py index c1a8bf4..cd22bc1 100644 --- a/jackify/backend/handlers/modlist_install_cli_ttw.py +++ b/jackify/backend/handlers/modlist_install_cli_ttw.py @@ -7,6 +7,7 @@ import shutil from pathlib import Path from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING +from jackify.shared.paths import get_jackify_logs_dir logger = logging.getLogger(__name__) @@ -352,10 +353,16 @@ class ModlistInstallCLITTWMixin: print(f"\nTTW has been installed to: {ttw_output_path}") print(f"TTW has been integrated into '{modlist_name}' (modlist.txt + plugins.txt updated).") print(f"The modlist '{modlist_name}' is now ready to use with TTW.") + print(f"Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}") + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") else: print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}") print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}") + print(f"{COLOR_INFO}Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}{COLOR_RESET}") + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") except Exception as e: self.logger.error(f"Error during TTW installation: {e}", exc_info=True) print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}") + print(f"{COLOR_INFO}Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}{COLOR_RESET}") + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") diff --git a/jackify/backend/handlers/path_handler_mo2.py b/jackify/backend/handlers/path_handler_mo2.py index b5ccacd..46b62b5 100644 --- a/jackify/backend/handlers/path_handler_mo2.py +++ b/jackify/backend/handlers/path_handler_mo2.py @@ -28,6 +28,95 @@ SDCARD_PREFIX = '/run/media/mmcblk0p1/' class PathHandlerMO2Mixin: """Mixin providing ModOrganizer.ini path updates and formatting.""" + @staticmethod + def _desired_home_basis_from_modlist_dir(modlist_dir_path: Path) -> Optional[str]: + """ + Determine desired Linux home-path basis from modlist install directory. + + Returns: + "/var/home" when modlist dir is under /var/home, + "/home" when modlist dir is under /home, + None otherwise. + """ + try: + posix = modlist_dir_path.as_posix() + except Exception: + posix = str(modlist_dir_path).replace("\\", "/") + if posix.startswith("/var/home/"): + return "/var/home" + if posix.startswith("/home/"): + return "/home" + return None + + @staticmethod + def _rewrite_z_home_basis_in_line(line: str, desired_home_basis: str) -> str: + """ + Rewrite only Z:-drive /home -> /var/home path basis in a single INI line. + + Preserves slash style (forward or backslash), and leaves D: paths untouched. + """ + if desired_home_basis == "/var/home": + # Z:/home/... -> Z:/var/home/... + # Z:\\home\\... -> Z:\\var\\home\\... + return re.sub(r'([Zz]:[/\\]+)home([/\\]+)', r'\1var\2home\2', line) + return line + + def align_home_path_basis(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool) -> bool: + """ + Align gamePath/binary/workingDirectory home-path basis to modlist_dir_path. + + This is a targeted post-processing step for Z: paths only: + - If install path is /var/home/... then rewrite Z:/home/... to Z:/var/home/... + - Otherwise do nothing. + """ + if modlist_sdcard: + return True + desired_home_basis = self._desired_home_basis_from_modlist_dir(modlist_dir_path) + # This alignment pass is intentionally one-way: + # only promote Z:/home -> Z:/var/home when install dir uses /var/home. + if desired_home_basis != "/var/home": + return True + if not modlist_ini_path.is_file(): + logger.error(f"INI file {modlist_ini_path} does not exist for home-basis alignment") + return False + try: + with open(modlist_ini_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + changed = 0 + for i, line in enumerate(lines): + stripped = line.strip() + if not ( + re.match(r'^\s*gamepath\s*=.*$', stripped, re.IGNORECASE) + or re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE) + or re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE) + ): + continue + rewritten = self._rewrite_z_home_basis_in_line(line, desired_home_basis) + if rewritten != line: + lines[i] = rewritten + changed += 1 + + if changed > 0: + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + logger.info( + "Aligned ModOrganizer.ini home-path basis to %s for %d line(s): %s", + desired_home_basis, + changed, + modlist_ini_path, + ) + else: + logger.debug( + "No home-path basis alignment needed for %s (target %s)", + modlist_ini_path, + desired_home_basis, + ) + return True + except Exception as e: + logger.error(f"Error aligning home path basis in {modlist_ini_path}: {e}") + return False + @staticmethod def _strip_sdcard_path_prefix(path_obj: Path) -> str: """Removes SD card mount prefix. Returns path as POSIX-style string.""" diff --git a/jackify/backend/handlers/path_handler_steam.py b/jackify/backend/handlers/path_handler_steam.py index 7643296..f15bd4a 100644 --- a/jackify/backend/handlers/path_handler_steam.py +++ b/jackify/backend/handlers/path_handler_steam.py @@ -12,6 +12,10 @@ from pathlib import Path from typing import Optional, List from datetime import datetime import vdf +from jackify.shared.steam_utils import ( + get_ordered_steam_roots, + STEAM_PREFERENCE_AUTO, +) logger = logging.getLogger(__name__) @@ -23,11 +27,7 @@ class PathHandlerSteamMixin: def find_steam_config_vdf() -> Optional[Path]: """Finds the active Steam config.vdf file.""" logger.debug("Searching for Steam config.vdf...") - possible_steam_paths = [ - Path.home() / ".steam/steam", - Path.home() / ".local/share/Steam", - Path.home() / ".steam/root" - ] + possible_steam_paths = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO) for steam_path in possible_steam_paths: potential_path = steam_path / "config/config.vdf" if potential_path.is_file(): @@ -40,10 +40,9 @@ class PathHandlerSteamMixin: def find_steam_library() -> Optional[Path]: """Find the primary Steam library common directory containing games.""" logger.debug("Attempting to find Steam library...") + ordered_roots = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO) libraryfolders_vdf_paths = [ - os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"), - os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"), - os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf"), + str(root / "config" / "libraryfolders.vdf") for root in ordered_roots ] for path in libraryfolders_vdf_paths: if os.path.exists(path): @@ -92,14 +91,11 @@ class PathHandlerSteamMixin: logger.info(f"Using Steam library common path: {library_paths[0]}") return library_paths[0] logger.debug("No valid common paths found in VDF, checking default location...") - default_common_path = Path.home() / ".steam/steam/steamapps/common" - if default_common_path.is_dir(): - logger.info(f"Using default Steam library common path: {default_common_path}") - return default_common_path - default_common_path_local = Path.home() / ".local/share/Steam/steamapps/common" - if default_common_path_local.is_dir(): - logger.info(f"Using default local Steam library common path: {default_common_path_local}") - return default_common_path_local + for root in ordered_roots: + default_common_path = root / "steamapps" / "common" + if default_common_path.is_dir(): + logger.info(f"Using default Steam library common path: {default_common_path}") + return default_common_path logger.error("No valid Steam library common path found in VDF or default locations.") return None except Exception as e: @@ -181,12 +177,8 @@ class PathHandlerSteamMixin: def get_all_steam_library_paths() -> List[Path]: """Finds all Steam library paths listed in all known libraryfolders.vdf files (including Flatpak).""" logger.info("[DEBUG] Searching for all Steam libraryfolders.vdf files...") - vdf_paths = [ - Path.home() / ".steam/steam/config/libraryfolders.vdf", - Path.home() / ".local/share/Steam/config/libraryfolders.vdf", - Path.home() / ".steam/root/config/libraryfolders.vdf", - Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf", - ] + ordered_roots = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO) + vdf_paths = [root / "config" / "libraryfolders.vdf" for root in ordered_roots] library_paths = set() for vdf_path in vdf_paths: if vdf_path.is_file(): diff --git a/jackify/backend/handlers/subprocess_utils.py b/jackify/backend/handlers/subprocess_utils.py index db23d2b..3db315b 100644 --- a/jackify/backend/handlers/subprocess_utils.py +++ b/jackify/backend/handlers/subprocess_utils.py @@ -6,6 +6,7 @@ import resource import sys import shutil import logging +import threading def get_safe_python_executable(): """ @@ -154,7 +155,7 @@ class ProcessManager: """ Shared process manager for robust subprocess launching, tracking, and cancellation. """ - def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False): + def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False): self.cmd = cmd # Default to cleaned environment if None to prevent AppImage variable inheritance if env is None: @@ -165,14 +166,18 @@ class ProcessManager: self.text = text self.bufsize = bufsize self.separate_stderr = separate_stderr + self.enable_stdin = enable_stdin self.proc = None self.process_group_pid = None + self._stdin_lock = threading.Lock() self._start_process() def _start_process(self): stderr_arg = subprocess.PIPE if self.separate_stderr else subprocess.STDOUT + stdin_arg = subprocess.PIPE if self.enable_stdin else None self.proc = subprocess.Popen( self.cmd, + stdin=stdin_arg, stdout=subprocess.PIPE, stderr=stderr_arg, env=self.env, @@ -190,31 +195,45 @@ class ProcessManager: cleanup_attempts = 0 try: if self.proc: + # Terminate process group first so child tools don't survive parent exit. + if self.process_group_pid: + try: + os.killpg(self.process_group_pid, signal.SIGTERM) + except Exception: + pass + try: self.proc.terminate() - try: - self.proc.wait(timeout=timeout_terminate) - return - except subprocess.TimeoutExpired: - pass except Exception: pass + try: - self.proc.kill() - try: - self.proc.wait(timeout=timeout_kill) - return - except subprocess.TimeoutExpired: - pass + self.proc.wait(timeout=timeout_terminate) + except subprocess.TimeoutExpired: + pass except Exception: pass - # Kill entire process group (catches 7zz and other child processes) + + # Escalate to SIGKILL for stubborn children/process group. if self.process_group_pid: try: os.killpg(self.process_group_pid, signal.SIGKILL) except Exception: pass - # Last resort: pkill by command name + + try: + self.proc.kill() + except Exception: + pass + + try: + self.proc.wait(timeout=timeout_kill) + except subprocess.TimeoutExpired: + pass + except Exception: + pass + + # Last resort: pkill by command name (kept bounded). while cleanup_attempts < max_cleanup_attempts: try: subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True) @@ -224,7 +243,7 @@ class ProcessManager: finally: # Always close pipes — unblocks threads blocked on read(1) or iterating stderr if self.proc: - for pipe in (self.proc.stdout, self.proc.stderr): + for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr): if pipe: try: pipe.close() @@ -250,4 +269,20 @@ class ProcessManager: return self.proc.stdout.read(1) except (ValueError, OSError): return None - return None \ No newline at end of file + return None + + def write_stdin(self, line: str) -> bool: + """ + Write a line to the process stdin. Thread-safe. + Returns True on success, False if stdin is not available or process is gone. + """ + if not self.enable_stdin or not self.proc or not self.proc.stdin: + return False + with self._stdin_lock: + try: + payload = line if line.endswith('\n') else line + '\n' + self.proc.stdin.write(payload.encode()) + self.proc.stdin.flush() + return True + except (OSError, BrokenPipeError): + return False diff --git a/jackify/backend/handlers/ttw_installer_backend.py b/jackify/backend/handlers/ttw_installer_backend.py index 532a527..1b5a168 100644 --- a/jackify/backend/handlers/ttw_installer_backend.py +++ b/jackify/backend/handlers/ttw_installer_backend.py @@ -64,17 +64,29 @@ class TTWInstallerBackendMixin: stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True ) + error_context: list = [] + capturing_explanation = False if process.stdout: for line in process.stdout: line = line.rstrip() if line: self.logger.info("TTW_Linux_Installer: %s", line) + lower = line.lower() + if 'failed' in lower or 'cannot continue' in lower or 'error:' in lower: + error_context.append(line.strip()) + capturing_explanation = True + elif capturing_explanation and line.startswith(' '): + error_context.append(line.strip()) + else: + capturing_explanation = False process.wait() ret = process.returncode if ret == 0: self.logger.info("TTW installation completed successfully.") return True, "TTW installation completed successfully!" self.logger.error("TTW installation process returned non-zero exit code: %s", ret) + if error_context: + return False, "TTW installation failed:\n" + "\n".join(error_context) return False, f"TTW installation failed with exit code {ret}" except Exception as e: self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True) @@ -210,6 +222,8 @@ class TTWInstallerBackendMixin: stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True ) + error_context: list = [] + capturing_explanation = False if process.stdout: for line in process.stdout: line = line.rstrip() @@ -217,12 +231,22 @@ class TTWInstallerBackendMixin: self.logger.info("TTW_Linux_Installer: %s", line) if output_callback: output_callback(line) + lower = line.lower() + if 'failed' in lower or 'cannot continue' in lower or 'error:' in lower: + error_context.append(line.strip()) + capturing_explanation = True + elif capturing_explanation and line.startswith(' '): + error_context.append(line.strip()) + else: + capturing_explanation = False process.wait() ret = process.returncode if ret == 0: self.logger.info("TTW installation completed successfully.") return True, "TTW installation completed successfully!" self.logger.error("TTW installation process returned non-zero exit code: %s", ret) + if error_context: + return False, "TTW installation failed:\n" + "\n".join(error_context) return False, f"TTW installation failed with exit code {ret}" except Exception as e: self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True) diff --git a/jackify/backend/services/automated_prefix_service.py b/jackify/backend/services/automated_prefix_service.py index 2d1ef2f..f1ac03f 100644 --- a/jackify/backend/services/automated_prefix_service.py +++ b/jackify/backend/services/automated_prefix_service.py @@ -269,10 +269,8 @@ exit""" def get_ttw_installer_path() -> Optional[Path]: """Get path to TTW_Linux_Installer if available""" try: - from jackify.shared.paths import get_jackify_data_dir - ttw_path = get_jackify_data_dir() / "TTW_Linux_Installer" / "ttw_linux_gui" - if ttw_path.exists(): - return ttw_path + from .ttw_installer_service import get_ttw_installer_path + return get_ttw_installer_path() except Exception: pass return None @@ -405,4 +403,3 @@ exit""" return prefix_dir else: return None - diff --git a/jackify/backend/services/automated_prefix_workflow.py b/jackify/backend/services/automated_prefix_workflow.py index b1c874b..5cec4ed 100644 --- a/jackify/backend/services/automated_prefix_workflow.py +++ b/jackify/backend/services/automated_prefix_workflow.py @@ -47,11 +47,19 @@ class WorkflowMixin: startdir_matches = shortcut_startdir == modlist_install_dir if (name_matches and (exe_matches or startdir_matches)): + raw_appid = shortcut.get('appid') + normalized_appid = None + if raw_appid is not None: + try: + normalized_appid = str(int(raw_appid) & 0xFFFFFFFF) + except Exception: + normalized_appid = str(raw_appid) conflicts.append({ 'index': i, 'name': name, 'exe': shortcut_exe, - 'startdir': shortcut_startdir + 'startdir': shortcut_startdir, + 'appid': normalized_appid, }) if conflicts: @@ -124,42 +132,59 @@ class WorkflowMixin: Tuple of (success, prefix_path, appid, last_timestamp) """ logger.info("Starting proven working automated prefix creation workflow") - - # Show installation complete and configuration start headers FIRST - if progress_callback: - progress_callback("") - progress_callback("=" * 64) - progress_callback("= Installation phase complete =") - progress_callback("=" * 64) - progress_callback("") - progress_callback("=" * 64) - progress_callback("= Starting Configuration Phase =") - progress_callback("=" * 64) - progress_callback("") - - # Reset timing for Steam Integration section (part of Configuration Phase) - from jackify.shared.timing import start_new_phase - start_new_phase() - - # Show immediate feedback to user with section header - if progress_callback: - progress_callback("") # Blank line before Steam Integration - progress_callback("=== Steam Integration ===") - progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service") - - # Registry injection approach for both FNV and Enderal - from ..handlers.modlist_handler import ModlistHandler - 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") - else: - logger.debug("Standard modlist - no special game handling needed") - try: + conflict_result = self.handle_existing_shortcut_conflict( + shortcut_name, + final_exe_path, + modlist_install_dir, + ) + if isinstance(conflict_result, list): + logger.warning( + "Found %d existing shortcut(s) with same name and path before Steam integration", + len(conflict_result), + ) + return ("CONFLICT", conflict_result, None, None) + if conflict_result is False: + logger.error("User cancelled due to shortcut conflict") + return False, None, None, None + + # Show installation complete and configuration start headers only after + # conflict checks pass, so users do not see Steam integration start + # messages when Jackify is about to stop for duplicate-shortcut review. + if progress_callback: + progress_callback("") + progress_callback("=" * 64) + progress_callback("= Installation phase complete =") + progress_callback("=" * 64) + progress_callback("") + progress_callback("=" * 64) + progress_callback("= Starting Configuration Phase =") + progress_callback("=" * 64) + progress_callback("") + + # Reset timing for Steam Integration section (part of Configuration Phase) + from jackify.shared.timing import start_new_phase + start_new_phase() + + # Show immediate feedback to user with section header + if progress_callback: + progress_callback("") # Blank line before Steam Integration + progress_callback("=== Steam Integration ===") + progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service") + + # Registry injection approach for both FNV and Enderal + from ..handlers.modlist_handler import ModlistHandler + 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") + else: + logger.debug("Standard modlist - no special game handling needed") + # Step 0: Shut down Steam before modifying VDF files # Required to safely modify shortcuts.vdf and config.vdf without race conditions logger.info("Step 0: Shutting down Steam before modifying VDF files") @@ -179,22 +204,6 @@ class WorkflowMixin: # Step 1: Create shortcut with native Steam service (Steam is now shut down) logger.info("Step 1: Creating shortcut with native Steam service") - - # DISABLED: Shortcut conflict detection temporarily disabled pending rework - # Re-enable after conflict resolution workflow refactor - # When re-enabled, this will detect and handle cases where shortcuts with the same - # name and path already exist in Steam, allowing users to resolve conflicts - # Disabled pending workflow improvements - planned for future release - # conflict_result = self.handle_existing_shortcut_conflict(shortcut_name, final_exe_path, modlist_install_dir) - # if isinstance(conflict_result, list): # Conflicts found - # logger.warning(f"Found {len(conflict_result)} existing shortcut(s) with same name and path") - # # Return a special tuple to indicate conflict that needs user resolution - # return ("CONFLICT", conflict_result, None) - # elif not conflict_result: # User cancelled or other failure - # logger.error("User cancelled due to shortcut conflict") - # return False, None, None, None - logger.info("Conflict detection temporarily disabled - proceeding with shortcut creation") - # Create shortcut using native Steam service with special game launch options success, appid = self.create_shortcut_with_native_service( shortcut_name, final_exe_path, modlist_install_dir, custom_launch_options, download_dir=download_dir @@ -387,4 +396,3 @@ class WorkflowMixin: if progress_callback: progress_callback(f"Error: {str(e)}") return False, None, None, None - diff --git a/jackify/backend/services/download_watcher_service.py b/jackify/backend/services/download_watcher_service.py new file mode 100644 index 0000000..815af11 --- /dev/null +++ b/jackify/backend/services/download_watcher_service.py @@ -0,0 +1,138 @@ +""" +Watches a directory for newly downloaded files and matches them against a +list of pending manual download items by lax filename comparison. +""" + +import os +import time +import logging +from dataclasses import dataclass, field +from pathlib import Path +from threading import Thread, Event +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + +@dataclass +class WatcherConfig: + watch_directory: Path + watch_recursive: bool = False + debounce_seconds: float = 2.0 + additional_dirs: list = field(default_factory=list) + + +class DownloadWatcherService: + """ + Monitors a directory for files that match pending download items. + + Caller sets pending_items (list of dicts with at least 'file_name') and + registers an on_candidate callback that receives (Path, dict) when a + potential match is detected (after debounce, before hash validation). + """ + + def __init__(self, config: WatcherConfig, on_candidate: Callable[[Path, dict], None]): + self._config = config + self._on_candidate = on_candidate + self._pending_items: list[dict] = [] + self._pending_exact: list[tuple[str, dict]] = [] + self._stop_event = Event() + self._thread: Optional[Thread] = None + # Track known files so we only react to new/changed ones + self._known: dict[Path, float] = {} + + def set_pending_items(self, items: list[dict]) -> None: + """Replace the pending items list. Thread-safe for simple list swap.""" + self._pending_items = list(items) + self._pending_exact = [ + (str(item.get('file_name', '')).lower(), item) + for item in self._pending_items + if item.get('file_name') + ] + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._stop_event.clear() + self._thread = Thread(target=self._watch_loop, daemon=True, name='DownloadWatcher') + self._thread.start() + logger.debug(f"Download watcher started on: {self._config.watch_directory}") + + def stop(self) -> None: + self._stop_event.set() + if self._thread: + self._thread.join(timeout=5) + logger.debug("Download watcher stopped") + + def _all_watch_dirs(self) -> list[Path]: + dirs = [self._config.watch_directory] + dirs.extend(self._config.additional_dirs) + return [d for d in dirs if d.is_dir()] + + def _scan(self) -> None: + for watch_dir in self._all_watch_dirs(): + try: + entries = list(watch_dir.iterdir()) if not self._config.watch_recursive else \ + [p for p in watch_dir.rglob('*') if p.is_file()] + for path in entries: + if not path.is_file(): + continue + # Skip browser temp files + if path.suffix in ('.part', '.crdownload', '.tmp'): + continue + try: + mtime = path.stat().st_mtime + except OSError: + continue + prev_mtime = self._known.get(path) + if prev_mtime == mtime: + continue + self._known[path] = mtime + self._check_candidate(path) + except OSError as e: + logger.debug(f"Watcher scan error on {watch_dir}: {e}") + + def _check_candidate(self, path: Path) -> None: + candidate_name = path.name.lower() + # Exact filename match (case-insensitive). + for expected_name, item in self._pending_exact: + if expected_name == candidate_name: + logger.debug(f"Candidate exact match: {path.name}") + self._debounce_and_emit(path, item) + return + # Some modlist metadata stores filenames with a leading dot that browsers + # strip when saving the download. Match against the stripped expected name. + for expected_name, item in self._pending_exact: + if expected_name.lstrip('.') == candidate_name: + logger.debug(f"Candidate dot-normalized match: {path.name} -> {expected_name}") + self._debounce_and_emit(path, item) + return + + def _debounce_and_emit(self, path: Path, item: dict) -> None: + def _wait_and_emit(): + prev_size = -1 + stable_count = 0 + needed = max(1, int(self._config.debounce_seconds / 0.5)) + for _ in range(needed * 4): # max ~2× debounce time + if self._stop_event.is_set(): + return + time.sleep(0.5) + try: + size = path.stat().st_size + except OSError: + return + if size == prev_size: + stable_count += 1 + if stable_count >= needed: + break + else: + stable_count = 0 + prev_size = size + if path.exists(): + self._on_candidate(path, item) + + Thread(target=_wait_and_emit, daemon=True, name=f'Debounce-{path.name[:20]}').start() + + def _watch_loop(self) -> None: + while not self._stop_event.is_set(): + self._scan() + self._stop_event.wait(timeout=1.0) diff --git a/jackify/backend/services/file_validator_service.py b/jackify/backend/services/file_validator_service.py new file mode 100644 index 0000000..9436deb --- /dev/null +++ b/jackify/backend/services/file_validator_service.py @@ -0,0 +1,261 @@ +""" +Hash validation and file move for manually downloaded archives. +Uses xxhash64 to match the engine's hash format exactly. +""" + +import struct +import shutil +import logging +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + +# xxhash produces 16-char lowercase hex with no prefix - matches engine Hash.ToHex() +# C extension is ABI-locked to the Python version it was compiled against, so +# AppImage builds need a pure-Python fallback for cross-version compatibility. +try: + import xxhash + _XXHASH_IMPL = 'native' +except ImportError: + xxhash = None + _XXHASH_IMPL = 'fallback' + logger.info("xxhash C extension not available, using pure-Python fallback") + + +class _XXH64Fallback: + """Pure-Python xxhash64 implementation for when the C extension can't load. + Reference: https://github.com/Cyan4973/xxHash/blob/dev/doc/xxhash_spec.md""" + + _P1 = 11400714785074694791 + _P2 = 14029467366897019727 + _P3 = 1609587929392839161 + _P4 = 9650029242287828579 + _P5 = 2870177450012600261 + _M64 = 0xFFFFFFFFFFFFFFFF + + def __init__(self, seed: int = 0): + self._seed = seed & self._M64 + self._total_len = 0 + self._buf = b"" + self._v1 = (seed + self._P1 + self._P2) & self._M64 + self._v2 = (seed + self._P2) & self._M64 + self._v3 = seed & self._M64 + self._v4 = (seed - self._P1) & self._M64 + + @staticmethod + def _rotl64(x: int, r: int) -> int: + return ((x << r) | (x >> (64 - r))) & 0xFFFFFFFFFFFFFFFF + + def _round(self, acc: int, inp: int) -> int: + acc = (acc + inp * self._P2) & self._M64 + acc = self._rotl64(acc, 31) + acc = (acc * self._P1) & self._M64 + return acc + + def _merge_round(self, acc: int, val: int) -> int: + val = self._round(0, val) + acc ^= val + acc = (acc * self._P1 + self._P4) & self._M64 + return acc + + def update(self, data: bytes) -> None: + self._buf += data + self._total_len += len(data) + + if len(self._buf) < 32: + return + + p = 0 + end = len(self._buf) - 31 # process 32-byte blocks + + while p < end: + self._v1 = self._round(self._v1, struct.unpack_from(' str: + return format(self._digest(), '016x') + + def _digest(self) -> int: + M = self._M64 + if self._total_len >= 32: + h = self._rotl64(self._v1, 1) + h = (h + self._rotl64(self._v2, 7)) & M + h = (h + self._rotl64(self._v3, 12)) & M + h = (h + self._rotl64(self._v4, 18)) & M + h = self._merge_round(h, self._v1) + h = self._merge_round(h, self._v2) + h = self._merge_round(h, self._v3) + h = self._merge_round(h, self._v4) + else: + h = (self._seed + self._P5) & M + + h = (h + self._total_len) & M + + buf = self._buf + p = 0 + remaining = len(buf) + + while remaining >= 8: + k1 = struct.unpack_from('= 4: + k1 = struct.unpack_from(' 0: + h ^= (buf[p] * self._P5) & M + h = (self._rotl64(h, 11) * self._P1) & M + p += 1 + remaining -= 1 + + # Avalanche + h ^= h >> 33 + h = (h * self._P2) & M + h ^= h >> 29 + h = (h * self._P3) & M + h ^= h >> 32 + return h + +_CHUNK = 1024 * 1024 # 1 MB + + +def _reverse_hex_byte_order(hex_value: str) -> str: + """Reverse byte order of a hex string (e.g. aabbccdd -> ddccbbaa).""" + value = (hex_value or "").strip().lower() + if len(value) % 2 != 0: + return value + return "".join(reversed([value[i:i + 2] for i in range(0, len(value), 2)])) + + +def _hash_matches_expected(computed_hash: str, expected_hash: str) -> bool: + """Accept either canonical or byte-reversed xxhash64 representations.""" + computed = (computed_hash or "").strip().lower() + expected = (expected_hash or "").strip().lower() + if not computed or not expected: + return False + return computed == expected or _reverse_hex_byte_order(computed) == expected + + +@dataclass +class ValidationResult: + matches: bool + computed_hash: Optional[str] + file_path: Path + error: Optional[str] = None + + +class FileValidatorService: + """ + Validates downloaded files against expected xxhash64 and moves them to + the modlist downloads directory on success. + """ + + def __init__(self, max_workers: int = 2): + self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='FileValidator') + + def validate_async( + self, + file_path: Path, + expected_hash: str, + modlist_download_dir: Path, + on_result: Callable[[ValidationResult, Optional[Path]], None], + dest_name: Optional[str] = None, + ) -> None: + """ + Validate file_path against expected_hash in a thread pool worker. + on_result(result, dest_path) is called on the worker thread when done. + dest_path is the moved file location if validation succeeded, else None. + dest_name overrides the destination filename (used when the engine's + canonical name differs from the downloaded file's name, e.g. leading dot). + """ + self._executor.submit( + self._validate_and_move, + file_path, expected_hash, modlist_download_dir, on_result, dest_name + ) + + def _validate_and_move( + self, + file_path: Path, + expected_hash: str, + modlist_download_dir: Path, + on_result: Callable, + dest_name: Optional[str] = None, + ) -> None: + result = self._validate(file_path, expected_hash) + dest: Optional[Path] = None + if result.matches: + try: + dest = self._move_file(file_path, modlist_download_dir, dest_name=dest_name) + logger.info( + "[MDL-1026] Archive move complete | " + f"source_path={file_path} destination_path={dest} hash={result.computed_hash or 'missing'}" + ) + except OSError as e: + logger.warning( + "[MDL-9020] Archive move failed after hash validation | " + f"source_path={file_path} destination_dir={modlist_download_dir} reason={e}" + ) + result = ValidationResult( + matches=False, + computed_hash=result.computed_hash, + file_path=file_path, + error=f"Move failed: {e}", + ) + on_result(result, dest) + + def _validate(self, file_path: Path, expected_hash: str) -> ValidationResult: + try: + # 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() + with open(file_path, 'rb') as f: + while True: + chunk = f.read(_CHUNK) + if not chunk: + break + h.update(chunk) + computed = h.hexdigest().lower() # 16-char lowercase hex, no prefix + matches = _hash_matches_expected(computed, expected_hash) + return ValidationResult( + matches=matches, + computed_hash=computed, + file_path=file_path, + ) + except OSError as e: + return ValidationResult(matches=False, computed_hash=None, file_path=file_path, + error=str(e)) + + def _move_file(self, source: Path, dest_dir: Path, dest_name: Optional[str] = None) -> Path: + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / (dest_name if dest_name else source.name) + # If the watched file is already in the modlist downloads directory, + # treat it as in-place and avoid a same-path move error. + try: + if source.resolve() == dest.resolve(): + logger.debug(f"Validated file already in modlist downloads directory: {source}") + return dest + except OSError: + pass + shutil.move(str(source), str(dest)) + logger.debug(f"Moved validated file: {source.name} -> {dest}") + return dest + + def shutdown(self) -> None: + self._executor.shutdown(wait=False) diff --git a/jackify/backend/services/manual_download_manager.py b/jackify/backend/services/manual_download_manager.py new file mode 100644 index 0000000..1eba569 --- /dev/null +++ b/jackify/backend/services/manual_download_manager.py @@ -0,0 +1,124 @@ +""" +Orchestrates the manual download workflow: +- Maintains queue of pending items +- Opens browser tabs (sliding window, N concurrent) +- Coordinates directory watcher and file validator +- Sends continue command to engine when all items are done +""" + +import logging +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Literal, Optional + +from jackify.backend.services.download_watcher_service import DownloadWatcherService, WatcherConfig +from jackify.backend.services.file_validator_service import FileValidatorService +from jackify.backend.services.manual_download_manager_api_mixin import ManualDownloadManagerApiMixin +from jackify.backend.services.manual_download_manager_runtime_mixin import ManualDownloadManagerRuntimeMixin + +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' + + +@dataclass +class DownloadItem: + file_name: str + nexus_url: str + expected_hash: str + expected_size: int + mod_name: str + mod_id: int = 0 + file_id: int = 0 + index: int = 0 + total: int = 0 + loop_iteration: int = 1 + status: STATUS = "pending" + local_path: Optional[str] = None + error_message: Optional[str] = None + needs_user_retry: bool = False + + @classmethod + def from_event(cls, evt: dict, loop_iteration: int = 1) -> 'DownloadItem': + # Engine historically emitted `nexus_url`, but manual-only/external sources + # may arrive as generic URL fields depending on engine version. + source_url = ( + evt.get('nexus_url') + or evt.get('download_url') + or evt.get('manual_url') + or evt.get('url') + or '' + ) + item = cls( + file_name=evt.get('file_name', ''), + nexus_url=source_url, + expected_hash=evt.get('expected_hash', ''), + expected_size=evt.get('expected_size', 0), + mod_name=evt.get('mod_name', evt.get('file_name', '')), + mod_id=evt.get('mod_id', 0), + file_id=evt.get('file_id', 0), + index=evt.get('index', 0), + total=evt.get('total', 0), + loop_iteration=loop_iteration, + ) + if not item.nexus_url: + # Engine contract says nexus_url should be present and non-empty. + # If missing, keep this item out of auto-open rotation and require + # explicit user attention/manual recovery. + item.needs_user_retry = True + item.error_message = "Malformed manual_download_required event: missing nexus_url" + return item + + +class ManualDownloadManager(ManualDownloadManagerApiMixin, ManualDownloadManagerRuntimeMixin): + """ + Manages the full manual download workflow for one engine session. + + Usage: + manager = ManualDownloadManager( + modlist_download_dir=Path(...), + watch_directory=Path(...), + concurrent_limit=2, + on_item_updated=my_callback, + on_send_continue=installer_thread.send_continue, + ) + manager.load_items(event_list, loop_iteration=1) + manager.start() + # ... user downloads files ... + # manager sends continue automatically when all done + manager.stop() + """ + + def __init__( + self, + modlist_download_dir: Path, + watch_directory: Path, + concurrent_limit: int = 2, + on_item_updated: Optional[Callable[[DownloadItem], None]] = None, + on_send_continue: Optional[Callable[[], None]] = None, + on_all_done: Optional[Callable[[int, int], None]] = None, + ): + self._dl_dir = modlist_download_dir + self._watch_dir = watch_directory + self._limit = max(1, min(5, concurrent_limit)) + self._on_item_updated = on_item_updated + self._on_send_continue = on_send_continue + self._on_all_done = on_all_done + + self._items: list[DownloadItem] = [] + self._lock = threading.Lock() + self._active_tabs = 0 + self._paused = False + self._started = False + self._startup_precheck_pending = 0 + self._run_id = f"mdl-{int(time.time())}-{id(self) % 10000}" + self._last_progress_log_completed = -1 + + additional = [modlist_download_dir] if modlist_download_dir != watch_directory else [] + config = WatcherConfig(watch_directory=watch_directory, additional_dirs=additional) + self._watcher = DownloadWatcherService(config, self._on_candidate) + self._validator = FileValidatorService(max_workers=2) diff --git a/jackify/backend/services/manual_download_manager_api_mixin.py b/jackify/backend/services/manual_download_manager_api_mixin.py new file mode 100644 index 0000000..14cb026 --- /dev/null +++ b/jackify/backend/services/manual_download_manager_api_mixin.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +"""Public API methods for ManualDownloadManager.""" + +import json +from typing import Optional + + +class ManualDownloadManagerApiMixin: + """Mixin containing public manager API methods and status properties.""" + def load_items(self, events: list[dict], loop_iteration: int = 1) -> None: + """ + Merge a new batch of engine events into the existing item list. + + On loop_iteration > 1, engine only emits still-missing files. Items NOT + in the new batch that were pending are confirmed present by the engine + (they passed its rescan) and are marked complete. Genuinely new items + (edge case) are appended. active_tabs resets so the sliding window + opens fresh tabs for the remaining items. + """ + with self._lock: + existing_map = {item.file_name: item for item in self._items} + new_batch_names = {evt.get('file_name', '') for evt in events} + + # Items the engine confirmed are now present (not in new batch, were pending) + for item in self._items: + if item.file_name not in new_batch_names and item.status not in ('complete', 'deferred', 'skipped', 'error'): + item.status = 'complete' + item.needs_user_retry = False + + # Recheck loop: clear temporary defer state for still-missing files so they can + # re-enter active browser rotation in the new iteration. + if loop_iteration > 1: + for item in self._items: + if item.file_name in new_batch_names and item.status in ('deferred', 'skipped'): + item.status = 'pending' + item.needs_user_retry = False + item.error_message = None + + # Add items genuinely not seen before (first iteration, or edge case) + for evt in events: + name = evt.get('file_name', '') + if name not in existing_map: + # Local import avoids module-load circular dependency with manager class. + from jackify.backend.services.manual_download_manager import DownloadItem + + new_item = DownloadItem.from_event(evt, loop_iteration) + self._items.append(new_item) + if not new_item.nexus_url: + self._diag( + "MDL-9012", + "Engine manual-download event missing required nexus_url", + level="error", + file_name=new_item.file_name or "missing", + loop_iteration=loop_iteration, + mod_id=new_item.mod_id, + file_id=new_item.file_id, + ) + + self._active_tabs = 0 + total = len(self._items) + pending = sum(1 for i in self._items if i.status == 'pending') + complete = sum(1 for i in self._items if i.status == 'complete') + skipped = sum(1 for i in self._items if i.status == 'skipped') + sample_pending = [i.file_name for i in self._items if i.status == 'pending'][:5] + self._refresh_watcher_pending_items() + self._diag( + "MDL-1001", + "Manual download batch loaded", + loop_iteration=loop_iteration, + batch_size=len(events), + total_items=total, + pending=pending, + complete=complete, + skipped=skipped, + pending_sample=json.dumps(sample_pending, ensure_ascii=True), + ) + + def start(self) -> None: + with self._lock: + if self._started: + return + self._started = True + self._diag( + "MDL-1002", + "Manual download watcher started", + watch_dir=str(self._watch_dir), + downloads_dir=str(self._dl_dir), + concurrent_limit=self._limit, + ) + self._watcher.start() + matched = self._ingest_existing_files() + with self._lock: + self._startup_precheck_pending = matched + if matched: + self._diag("MDL-1003", "Pre-existing archives detected", matched=matched) + self._diag("MDL-1016", "Deferring tab opening until precheck validation completes", pending_precheck=matched) + else: + self._open_next_tabs() + + def stop(self) -> None: + self._watcher.stop() + self._validator.shutdown() + with self._lock: + self._started = False + self._startup_precheck_pending = 0 + self._diag("MDL-1009", "Manual download manager stopped") + + def pause(self) -> None: + with self._lock: + self._paused = True + + def resume(self) -> None: + with self._lock: + self._paused = False + self._diag("MDL-1008", "Manual download resumed") + # Explicit user start/resume must open tabs even if startup precheck + # bookkeeping is still in-flight. + self._open_next_tabs(force_user_start=True) + + def skip_item(self, file_name: str) -> None: + item_to_notify: Optional[DownloadItem] = None + with self._lock: + for item in self._items: + if item.file_name == file_name and item.status not in ('complete',): + item.status = 'deferred' + if self._active_tabs > 0: + self._active_tabs -= 1 + item_to_notify = item + break + if item_to_notify is not None: + self._notify(item_to_notify) + self._open_next_tabs() + self._check_all_done() + + def set_concurrent_limit(self, limit: int) -> None: + with self._lock: + self._limit = max(1, min(5, limit)) + applied = self._limit + started = self._started + self._diag("MDL-1006", "Manual download concurrency updated", concurrent_limit=applied) + if started: + self._open_next_tabs() + + @property + def items(self) -> list[DownloadItem]: + with self._lock: + return list(self._items) + + @property + def pending_count(self) -> int: + with self._lock: + return sum(1 for i in self._items if i.status == 'pending') + + @property + def complete_count(self) -> int: + with self._lock: + return sum(1 for i in self._items if i.status == 'complete') + + @property + def skipped_count(self) -> int: + with self._lock: + return sum(1 for i in self._items if i.status in ('deferred', 'skipped')) diff --git a/jackify/backend/services/manual_download_manager_runtime_mixin.py b/jackify/backend/services/manual_download_manager_runtime_mixin.py new file mode 100644 index 0000000..d0f8b7e --- /dev/null +++ b/jackify/backend/services/manual_download_manager_runtime_mixin.py @@ -0,0 +1,479 @@ +from __future__ import annotations + +"""Internal runtime methods for ManualDownloadManager.""" + +import json +import logging +import subprocess +from pathlib import Path +from typing import Optional + +from jackify.backend.services.file_validator_service import ValidationResult + +logger = logging.getLogger(__name__) + + +class ManualDownloadManagerRuntimeMixin: + """Mixin containing browser/watcher/validation runtime methods.""" + def _open_next_tabs(self, force_user_start: bool = False) -> None: + to_open = [] + to_notify = [] + with self._lock: + if not self._started or self._paused: + return + if self._startup_precheck_pending > 0 and not force_user_start: + return + while self._active_tabs < self._limit: + item = self._next_pending(include_retry=force_user_start) + if item is None: + break + if force_user_start and item.needs_user_retry: + item.needs_user_retry = False + item.error_message = None + item.status = 'browser_opened' + self._active_tabs += 1 + to_notify.append(item) + to_open.append(item) + active_tabs = self._active_tabs + pending_left = sum(1 for i in self._items if i.status == 'pending') + if to_open: + self._diag( + "MDL-1010", + "Opening next manual download tab window", + opening_count=len(to_open), + active_tabs=active_tabs, + pending_after_schedule=pending_left, + ) + # Notify outside the lock to prevent GUI callbacks from re-entering manager state. + for item in to_notify: + self._notify(item) + # Open browser tabs outside the lock so Popen/fork doesn't stall lock holders + for item in to_open: + opened, error_message = self._open_browser(item) + if opened: + continue + item_to_notify: Optional[DownloadItem] = None + with self._lock: + # Revert failed launch so the row does not falsely remain "Browser Opened". + current = self._item_by_name(item.file_name) + if current and current.status == 'browser_opened': + current.status = 'pending' + if self._active_tabs > 0: + self._active_tabs -= 1 + current.error_message = error_message + if error_message and "No URL available" in error_message: + current.needs_user_retry = True + item_to_notify = current + if item_to_notify is not None: + self._notify(item_to_notify) + self._diag( + "MDL-9001", + "Automatic browser launch failed for manual download item", + level="warning", + file_name=item.file_name, + reason=error_message or "unknown launcher failure", + ) + + def _next_pending(self, include_retry: bool = False) -> Optional[DownloadItem]: + for item in self._items: + if item.status != 'pending': + continue + if item.needs_user_retry and not include_retry: + continue + return item + return None + + def _open_browser(self, item: DownloadItem) -> tuple[bool, Optional[str]]: + url = item.nexus_url + if not url: + msg = "No URL available for manual download item" + logger.warning(f"{msg}: {item.file_name}") + return False, msg + + # Linux desktop launch fallbacks. xdg-open should cover most environments, + # but keep alternates for distributions where handlers differ. + launch_cmds = ( + ['xdg-open', url], + ['gio', 'open', url], + ['sensible-browser', url], + ) + + launch_errors: list[str] = [] + for cmd in launch_cmds: + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + start_new_session=True, + ) + except OSError as e: + launch_errors.append(f"{cmd[0]} not available: {e}") + continue + + try: + rc = proc.wait(timeout=3) + except subprocess.TimeoutExpired: + # Launcher still running after handoff window; treat as success. + logger.debug(f"Opened browser for: {item.file_name} via {cmd[0]}") + return True, None + + if rc == 0: + logger.debug(f"Opened browser for: {item.file_name} via {cmd[0]}") + return True, None + + stderr_tail = "" + try: + stderr_tail = (proc.stderr.read() or b"").decode("utf-8", errors="replace").strip() + except Exception: + stderr_tail = "" + launch_errors.append(f"{cmd[0]} exited {rc}{(': ' + stderr_tail) if stderr_tail else ''}") + + msg = f"Could not open browser automatically for {item.file_name}" + logger.error(f"{msg}. Launch attempts: {' | '.join(launch_errors)}") + return False, msg + + def _on_candidate(self, path: Path, hint: dict, from_startup_precheck: bool = False) -> bool: + """Called by watcher after debounce when a potential match is found.""" + file_name = hint.get('file_name', '') + item_to_notify: Optional[DownloadItem] = None + reject_reason = "" + had_browser_slot = False + with self._lock: + item = self._item_by_name(file_name) + if item is None: + reject_reason = "unknown_item" + elif item.status in ('complete', 'skipped'): + reject_reason = f"terminal_status:{item.status}" + elif item.status == 'validating': + reject_reason = "already_validating" + if reject_reason: + self._diag( + "MDL-1022", + "Candidate ignored", + file_name=file_name or "missing", + source_path=str(path), + from_precheck=from_startup_precheck, + reason=reject_reason, + ) + return False + had_browser_slot = item.status == 'browser_opened' + item.status = 'validating' + item_to_notify = item + + if item_to_notify is not None: + self._notify(item_to_notify) + self._diag( + "MDL-1020", + "Candidate queued for validation", + file_name=file_name or "missing", + source_path=str(path), + from_precheck=from_startup_precheck, + ) + + # Pass the engine's canonical filename as dest_name so that if the browser + # stripped a leading dot, the file is renamed correctly on move. + canonical_name = hint.get('file_name') or None + dest_name = canonical_name if canonical_name and canonical_name != path.name else None + self._validator.validate_async( + file_path=path, + expected_hash=hint.get('expected_hash', ''), + modlist_download_dir=self._dl_dir, + on_result=lambda result, dest: self._on_validation_result( + file_name, + result, + dest, + from_startup_precheck=from_startup_precheck, + had_browser_slot=had_browser_slot, + ), + dest_name=dest_name, + ) + return True + + def _on_validation_result( + self, + file_name: str, + result: ValidationResult, + dest: Optional[Path], + from_startup_precheck: bool = False, + had_browser_slot: bool = False, + ) -> None: + item_to_notify: Optional[DownloadItem] = None + validation_failed = False + completed_now = False + precheck_ready = False + expected_hash = "" + mod_id = 0 + file_id = 0 + source_path = str(result.file_path) if getattr(result, "file_path", None) else "" + computed_hash = (result.computed_hash or "").lower() if result.computed_hash else "" + with self._lock: + item = self._item_by_name(file_name) + if item is None: + return + expected_hash = (item.expected_hash or "").lower() + mod_id = item.mod_id + file_id = item.file_id + if result.matches and dest: + item.status = 'complete' + item.local_path = str(dest) + item.needs_user_retry = False + if had_browser_slot and self._active_tabs > 0: + self._active_tabs -= 1 + item_to_notify = item + completed_now = True + else: + # Hash mismatch or validation error — revert to pending so the + # sliding window can re-open a browser tab and the watcher can + # re-validate if the user downloads the correct file. + item.status = 'pending' + msg = result.error or f"Hash mismatch (got {result.computed_hash})" + item.error_message = msg + logger.warning(f"Validation failed for {file_name}: {msg}") + if had_browser_slot and self._active_tabs > 0: + self._active_tabs -= 1 + item_to_notify = item + validation_failed = True + if from_startup_precheck and self._startup_precheck_pending > 0: + self._startup_precheck_pending -= 1 + precheck_ready = self._startup_precheck_pending == 0 + + if item_to_notify is not None: + self._notify(item_to_notify) + if completed_now: + self._diag( + "MDL-1021", + "Archive validated and accepted", + file_name=file_name, + source_path=source_path or "missing", + destination_path=str(dest) if dest else "missing", + expected_hash=expected_hash or "missing", + computed_hash=computed_hash or "missing", + from_precheck=from_startup_precheck, + mod_id=mod_id, + file_id=file_id, + ) + self._maybe_log_progress_summary() + if precheck_ready: + self._diag("MDL-1017", "Startup precheck validation complete; opening tabs") + self._open_next_tabs() + if validation_failed: + self._refresh_watcher_pending_items() + if not from_startup_precheck: + self._open_next_tabs() + self._diag( + "MDL-9002", + "Archive validation failed", + level="warning", + file_name=file_name, + expected_hash=expected_hash or "missing", + computed_hash=computed_hash or "missing", + source_path=source_path or "missing", + mod_id=mod_id, + file_id=file_id, + from_precheck=from_startup_precheck, + reason=result.error or "hash mismatch", + ) + return + + # Update watcher pending list (remove completed item, keep other in-flight items). + self._refresh_watcher_pending_items() + if not from_startup_precheck: + self._open_next_tabs() + self._check_all_done() + + def _check_all_done(self) -> None: + with self._lock: + remaining = [i for i in self._items if i.status not in ('complete', 'deferred', 'skipped', 'error')] + if remaining: + return + completed = sum(1 for i in self._items if i.status == 'complete') + skipped = sum(1 for i in self._items if i.status in ('deferred', 'skipped')) + + self._diag("MDL-1011", "Manual download phase completed", completed=completed, skipped=skipped) + if self._on_all_done: + self._on_all_done(completed, skipped) + if self._on_send_continue: + self._diag("MDL-1012", "Sending continue command to engine") + self._on_send_continue() + + def _item_by_name(self, file_name: str) -> Optional[DownloadItem]: + for item in self._items: + if item.file_name == file_name: + return item + return None + + def _refresh_watcher_pending_items(self) -> None: + """Keep watcher tracking all non-terminal items, not only pure 'pending' ones.""" + with self._lock: + pending_items = [ + {'file_name': i.file_name, 'expected_hash': i.expected_hash, 'expected_size': i.expected_size} + for i in self._items + if i.status not in ('complete', 'error') + ] + pending_count = len(pending_items) + sample_pending = [i['file_name'] for i in pending_items[:5]] + self._watcher.set_pending_items(pending_items) + self._diag( + "MDL-1019", + "Watcher pending list refreshed", + pending_count=pending_count, + pending_sample=json.dumps(sample_pending, ensure_ascii=True), + ) + + def _ingest_existing_files(self) -> int: + """ + Pre-check watch/modlist directories for already-present archives so users + do not need to re-download files that already exist. + """ + dirs: list[Path] = [] + if self._watch_dir.is_dir(): + dirs.append(self._watch_dir) + if self._dl_dir.is_dir() and self._dl_dir != self._watch_dir: + dirs.append(self._dl_dir) + if not dirs: + return 0 + + existing_files: list[Path] = [] + for d in dirs: + try: + for p in d.iterdir(): + if p.is_file() and p.suffix not in ('.part', '.crdownload', '.tmp'): + existing_files.append(p) + except OSError as e: + logger.warning(f"[MDL-9021] Precheck scan error: dir={d} reason={e}") + continue + + if not existing_files: + self._diag("MDL-1023", "Startup precheck found no candidate files", scan_dirs=len(dirs)) + return 0 + + exact_map: dict[str, Path] = {} + for p in existing_files: + exact_map.setdefault(p.name.lower(), p) + + with self._lock: + targets = [ + {'file_name': i.file_name, 'expected_hash': i.expected_hash, 'expected_size': i.expected_size} + for i in self._items + if i.status not in ('complete', 'error') + ] + + self._diag( + "MDL-1024", + "Startup precheck scan summary", + scan_dirs=len(dirs), + discovered_files=len(existing_files), + pending_targets=len(targets), + discovered_sample=json.dumps([p.name for p in existing_files[:5]], ensure_ascii=True), + target_sample=json.dumps([t.get('file_name', '') for t in targets[:5]], ensure_ascii=True), + ) + + matched = 0 + used_paths: set[Path] = set() + for hint in targets: + name = hint['file_name'] + exact = exact_map.get(name.lower()) + if exact is None: + # Leading-dot normalization: browser may strip a leading dot that + # the engine uses in its canonical filename. + stripped = name.lower().lstrip('.') + if stripped != name.lower(): + exact = exact_map.get(stripped) + if exact is None or exact in used_paths: + continue + used_paths.add(exact) + if self._on_candidate(exact, hint, from_startup_precheck=True): + matched += 1 + + if matched: + logger.info(f"[MDL-1025] Startup precheck queued {matched} archive(s) for validation") + else: + self._diag("MDL-1025", "Startup precheck found zero exact filename matches") + return matched + + def reopen_item(self, file_name: str) -> bool: + """Re-open a specific item's URL (e.g. if user closed browser tab accidentally).""" + notify_item: Optional[DownloadItem] = None + with self._lock: + item = self._item_by_name(file_name) + if item is None: + return False + if item.status in ('complete', 'skipped'): + return False + if item.status != 'browser_opened': + item.status = 'browser_opened' + item.needs_user_retry = False + self._active_tabs += 1 + notify_item = item + if notify_item is not None: + self._notify(notify_item) + + if item is None: + return False + opened, error = self._open_browser(item) + if not opened: + revert_item: Optional[DownloadItem] = None + with self._lock: + current = self._item_by_name(file_name) + if current is not None and current.status == 'browser_opened': + current.status = 'pending' + current.needs_user_retry = True + if self._active_tabs > 0: + self._active_tabs -= 1 + revert_item = current + if revert_item is not None: + self._notify(revert_item) + self._diag( + "MDL-9011", + "Manual reopen failed", + level="warning", + file_name=file_name, + reason=error or "unknown launcher failure", + ) + return False + self._diag("MDL-1018", "Manual item URL re-opened by user", file_name=file_name) + return True + + def _maybe_log_progress_summary(self) -> None: + with self._lock: + complete = sum(1 for i in self._items if i.status == 'complete') + skipped = sum(1 for i in self._items if i.status in ('deferred', 'skipped')) + pending = sum(1 for i in self._items if i.status == 'pending') + validating = sum(1 for i in self._items if i.status == 'validating') + opened = sum(1 for i in self._items if i.status == 'browser_opened') + total = len(self._items) + if complete == self._last_progress_log_completed: + return + if complete in (1, total) or complete % 10 == 0: + self._last_progress_log_completed = complete + self._diag( + "MDL-1013", + "Manual download progress summary", + total=total, + complete=complete, + browser_opened=opened, + validating=validating, + pending=pending, + skipped=skipped, + needs_retry=sum(1 for i in self._items if i.needs_user_retry), + ) + + def _diag(self, code: str, message: str, level: str = "info", **ctx) -> None: + details = " ".join(f"{k}={v}" for k, v in ctx.items()) + text = f"[{code}] run={self._run_id} {message}" + if details: + text = f"{text} | {details}" + if level == "warning": + logger.warning(text) + elif level == "error": + logger.error(text) + else: + logger.info(text) + + def _notify(self, item: DownloadItem) -> None: + if self._on_item_updated: + try: + self._on_item_updated(item) + except Exception as e: + logger.debug(f"on_item_updated callback error: {e}") diff --git a/jackify/backend/services/mo2_setup_service.py b/jackify/backend/services/mo2_setup_service.py index b2a24bc..1dd86bb 100644 --- a/jackify/backend/services/mo2_setup_service.py +++ b/jackify/backend/services/mo2_setup_service.py @@ -11,6 +11,8 @@ import re import shutil import logging import subprocess +import tempfile +import time from pathlib import Path from typing import Callable, Optional, Tuple @@ -31,10 +33,55 @@ class MO2SetupService: GITHUB_API = "https://api.github.com/repos/ModOrganizer2/modorganizer/releases/latest" ASSET_PATTERN = re.compile(r"Mod\.Organizer-\d+\.\d+(\.\d+)?\.7z$") + def _extract_archive( + self, + archive_path: Path, + install_dir: Path, + should_cancel: Optional[Callable[[], bool]] = None, + ) -> Tuple[bool, Optional[str]]: + """Extract the MO2 archive without interactive prompts and honor cancellation.""" + + process = None + try: + process = subprocess.Popen( + ['7z', 'x', '-y', '-aoa', str(archive_path), f'-o{install_dir}'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + while True: + if should_cancel and should_cancel(): + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + return False, "MO2 setup cancelled." + + returncode = process.poll() + if returncode is not None: + stdout, stderr = process.communicate() + if returncode != 0: + err = (stderr or stdout or "").strip() + return False, f"Extraction failed: {err or '7z returned a non-zero exit code.'}" + return True, None + + time.sleep(0.1) + except Exception as e: + if process is not None: + try: + process.kill() + except Exception: + pass + return False, f"Extraction failed: {e}" + def setup_mo2( self, install_dir: Path, shortcut_name: str = "Mod Organizer 2", + existing_appid: Optional[int] = None, progress_callback: Optional[Callable[[str], None]] = None, should_cancel: Optional[Callable[[], bool]] = None, ) -> Tuple[bool, Optional[int], Optional[str]]: @@ -88,16 +135,21 @@ class MO2SetupService: return False, None, "Could not find main MO2 .7z asset in latest release." # Download - archive_path = install_dir / asset['name'] _progress(f"Downloading {asset['name']}...") if _cancel_requested(): return False, None, "MO2 setup cancelled." try: + with tempfile.NamedTemporaryFile(prefix="jackify-mo2-", suffix=".7z", delete=False) as tmp_file: + archive_path = Path(tmp_file.name) with requests.get(asset['browser_download_url'], stream=True, timeout=120, verify=True) as r: r.raise_for_status() with open(archive_path, 'wb') as f: for chunk in r.iter_content(chunk_size=8192): if _cancel_requested(): + try: + archive_path.unlink(missing_ok=True) + except Exception: + pass return False, None, "MO2 setup cancelled." f.write(chunk) except Exception as e: @@ -107,18 +159,13 @@ class MO2SetupService: _progress(f"Extracting to {install_dir}...") if _cancel_requested(): return False, None, "MO2 setup cancelled." - try: - result = subprocess.run( - ['7z', 'x', str(archive_path), f'-o{install_dir}'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=1200, - ) - if result.returncode != 0: - err = result.stderr.decode(errors='ignore') - return False, None, f"Extraction failed: {err}" - except Exception as e: - return False, None, f"Extraction failed: {e}" + extract_ok, extract_error = self._extract_archive(archive_path, install_dir, should_cancel) + if not extract_ok: + try: + archive_path.unlink(missing_ok=True) + except Exception: + pass + return False, None, extract_error # Validate mo2_exe = install_dir / "ModOrganizer.exe" @@ -149,12 +196,22 @@ class MO2SetupService: try: from .automated_prefix_service import AutomatedPrefixService svc = AutomatedPrefixService() - success, prefix_path, app_id, _last_ts = svc.run_working_workflow( - shortcut_name=shortcut_name, - modlist_install_dir=str(install_dir), - final_exe_path=str(mo2_exe), - progress_callback=_progress, - ) + if existing_appid is not None: + app_id = int(existing_appid) + _progress(f"Reusing existing Steam shortcut with AppID: {app_id}") + prefix_path = svc.get_prefix_path(app_id) + if prefix_path is None: + if not svc.create_prefix_with_proton_wrapper(app_id): + return False, None, "Failed to create Proton prefix for existing shortcut." + prefix_path = svc.get_prefix_path(app_id) + success = True + else: + success, prefix_path, app_id, _last_ts = svc.run_working_workflow( + shortcut_name=shortcut_name, + modlist_install_dir=str(install_dir), + final_exe_path=str(mo2_exe), + progress_callback=_progress, + ) except Exception as e: logger.error(f"AutomatedPrefixService failed: {e}") return False, None, f"Prefix setup failed: {e}" diff --git a/jackify/backend/services/modlist_service.py b/jackify/backend/services/modlist_service.py index f15cef8..724cbce 100644 --- a/jackify/backend/services/modlist_service.py +++ b/jackify/backend/services/modlist_service.py @@ -334,9 +334,9 @@ class ModlistService(ModlistServiceInstallationMixin): if completion_callback: if success: - debug_callback("Configuration completed successfully, calling completion callback") + debug_callback("Core configuration complete, calling completion callback") # Pass ENB detection status through callback - completion_callback(True, "Configuration completed successfully!", context.name, enb_detected) + completion_callback(True, "Core configuration complete", context.name, enb_detected) else: debug_callback("Configuration failed, calling completion callback with failure") completion_callback(False, "Configuration failed", context.name, False) @@ -439,7 +439,7 @@ class ModlistService(ModlistServiceInstallationMixin): if success: logger.info("Modlist configuration completed successfully") if completion_callback: - completion_callback(True, "Configuration completed successfully", context.name, False) + completion_callback(True, "Core configuration complete", context.name, False) else: logger.warning("Modlist configuration had issues") if completion_callback: diff --git a/jackify/backend/services/modlist_service_installation.py b/jackify/backend/services/modlist_service_installation.py index e1d1b00..e63eaef 100644 --- a/jackify/backend/services/modlist_service_installation.py +++ b/jackify/backend/services/modlist_service_installation.py @@ -186,10 +186,23 @@ class ModlistServiceInstallationMixin: clean_env = get_clean_subprocess_env() proc = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir ) + def _write_stdin(line: str) -> bool: + try: + payload = line if line.endswith('\n') else line + '\n' + proc.stdin.write(payload.encode()) + proc.stdin.flush() + return True + except (OSError, BrokenPipeError): + return False + + from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename + import json as _json + _cc_filename = None + _pending_manual: list = [] buffer = b'' while True: chunk = proc.stdout.read(1) @@ -197,26 +210,81 @@ class ModlistServiceInstallationMixin: break buffer += chunk - if chunk == b'\n': + if chunk in (b'\n', b'\r'): line = buffer.decode('utf-8', errors='replace') - if output_callback: - output_callback(line.rstrip()) + decoded = line.rstrip() buffer = b'' - elif chunk == b'\r': - line = buffer.decode('utf-8', errors='replace') + + # JSON engine events - handle silently, don't pass to output_callback + if decoded.strip().startswith('{'): + try: + obj = _json.loads(decoded.strip()) + event = obj.get('event') + if event == 'manual_download_required': + _pending_manual.append(obj) + continue + if event == 'manual_download_list_complete': + loop_iter = obj.get('loop_iteration', 1) + for item in _pending_manual: + item['loop_iteration'] = loop_iter + items_batch = list(_pending_manual) + _pending_manual.clear() + from jackify.backend.handlers.config_handler import ConfigHandler + raw_limit = ConfigHandler().get('manual_download_concurrent_limit', 2) + try: + manual_limit = int(raw_limit) + except (TypeError, ValueError): + manual_limit = 2 + manual_limit = max(1, min(5, manual_limit)) + from jackify.frontends.cli.commands.manual_download_flow import run_cli_manual_download_phase + completed = run_cli_manual_download_phase( + events=items_batch, + loop_iteration=loop_iter, + download_dir=actual_download_path, + stdin_write=_write_stdin, + output_callback=output_callback, + concurrent_limit=manual_limit, + ) + if not completed: + if proc.poll() is None: + proc.terminate() + break + continue + if event == 'manual_download_phase_complete': + if output_callback: + found = obj.get('total_found', 0) + required = obj.get('total_required', 0) + output_callback(f"All manual downloads confirmed ({found}/{required}). Resuming installation...") + continue + except (_json.JSONDecodeError, ValueError): + pass + if output_callback: - output_callback(line.rstrip()) - buffer = b'' + output_callback(decoded) + if _cc_filename is None and is_cc_content_error(decoded): + _cc_filename = extract_cc_filename(decoded) or "" if buffer: line = buffer.decode('utf-8', errors='replace') + decoded = line.rstrip() if output_callback: - output_callback(line.rstrip()) + output_callback(decoded) + if _cc_filename is None and is_cc_content_error(decoded): + _cc_filename = extract_cc_filename(decoded) or "" proc.wait() if proc.returncode != 0: if output_callback: output_callback(f"Jackify Install Engine exited with code {proc.returncode}.") + if _cc_filename is not None and output_callback: + fname_note = f" ({_cc_filename})" if _cc_filename else "" + output_callback("") + output_callback(f"[WARN] Anniversary Edition Content Missing{fname_note}") + output_callback(" - Open Vanilla Skyrim SE/AE and let it run until all Creation Club content has downloaded.") + output_callback(" - From the Skyrim main menu, go into Creations and select 'Download All'.") + output_callback(" - If specific files are still missing, search for and download them from the Creations menu.") + output_callback(" - If problems persist, uninstall and reinstall Skyrim, then launch once to trigger the AE download.") + output_callback(" - Note: Skyrim AE via Steam Family Sharing does not transfer DLC content.") return False if output_callback: output_callback("Installation completed successfully") diff --git a/jackify/backend/services/native_steam_service.py b/jackify/backend/services/native_steam_service.py index a360dc7..3779709 100644 --- a/jackify/backend/services/native_steam_service.py +++ b/jackify/backend/services/native_steam_service.py @@ -16,6 +16,7 @@ from pathlib import Path from typing import Optional, Tuple, Dict, Any, List from ..handlers.vdf_handler import VDFHandler +from jackify.shared.steam_utils import get_ordered_steam_roots, STEAM_PREFERENCE_AUTO logger = logging.getLogger(__name__) @@ -30,13 +31,14 @@ class NativeSteamService: """ def __init__(self): - self.steam_paths = [ - Path.home() / ".steam" / "steam", - Path.home() / ".local" / "share" / "Steam", - Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam", - Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / ".local" / "share" / "Steam", - Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "home" / ".local" / "share" / "Steam" - ] + preference = STEAM_PREFERENCE_AUTO + try: + from jackify.backend.handlers.config_handler import ConfigHandler + + preference = ConfigHandler().get("steam_install_preference", STEAM_PREFERENCE_AUTO) + except Exception: + pass + self.steam_paths = get_ordered_steam_roots(preference=preference) self.steam_path = None self.userdata_path = None self.user_id = None @@ -620,4 +622,4 @@ class NativeSteamService: except Exception as e: logger.error(f"Error creating symlink: {e}") - return False \ No newline at end of file + return False diff --git a/jackify/backend/services/nexus_oauth_protocol.py b/jackify/backend/services/nexus_oauth_protocol.py index 904930b..def7ea4 100644 --- a/jackify/backend/services/nexus_oauth_protocol.py +++ b/jackify/backend/services/nexus_oauth_protocol.py @@ -26,6 +26,7 @@ class NexusOAuthProtocolMixin: 'APPIMAGE' in env or 'APPDIR' in env or (sys.argv[0] and sys.argv[0].endswith('.AppImage')) ) + exec_path_reliable = True if is_appimage: if 'APPIMAGE' in env: exec_path = env['APPIMAGE'] @@ -35,34 +36,27 @@ class NexusOAuthProtocolMixin: logger.info("Using resolved sys.argv[0]: %s", exec_path) else: exec_path = sys.argv[0] + exec_path_reliable = False logger.warning("Using sys.argv[0] as fallback: %s", exec_path) else: src_dir = Path(__file__).resolve().parent.parent.parent.parent exec_path = f'bash -c \'cd "{src_dir}" && "{sys.executable}" -m jackify.frontends.gui "$@"\' --' logger.info("DEV mode exec path: %s", exec_path) logger.info("Source directory: %s", src_dir) - needs_update = False - if not desktop_file.exists(): - needs_update = True - logger.info("Creating desktop file for protocol handler") - else: + + expected_exec = f'Exec="{exec_path}" %u' if is_appimage else f'Exec={exec_path} %u' + needs_write = not desktop_file.exists() + if not needs_write and exec_path_reliable: current_content = desktop_file.read_text() - if is_appimage: - expected_exec = f'Exec="{exec_path}" %u' - else: - expected_exec = f"Exec={exec_path} %u" if expected_exec not in current_content: - needs_update = True - logger.info("Updating desktop file with new Exec path: %s", exec_path) - if is_appimage and ' ' in exec_path: - import re - if re.search(r'Exec=[^"]\S*\s+\S*\.AppImage', current_content): - needs_update = True - logger.info("Fixing malformed desktop file (unquoted path with spaces)") - if needs_update: - desktop_file.parent.mkdir(parents=True, exist_ok=True) - if is_appimage: - desktop_content = f"""[Desktop Entry] + needs_write = True + logger.info("Desktop file Exec path outdated, updating: %s", exec_path) + elif not needs_write and not exec_path_reliable: + logger.warning("Could not reliably determine AppImage path, keeping existing desktop file") + + desktop_file.parent.mkdir(parents=True, exist_ok=True) + if needs_write and is_appimage: + desktop_content = f"""[Desktop Entry] Type=Application Name=Jackify Comment=Wabbajack modlist manager for Linux @@ -72,9 +66,9 @@ Terminal=false Categories=Game;Utility; MimeType=x-scheme-handler/jackify; """ - else: - src_dir = Path(__file__).resolve().parent.parent.parent.parent - desktop_content = f"""[Desktop Entry] + elif needs_write: + src_dir = Path(__file__).resolve().parent.parent.parent.parent + desktop_content = f"""[Desktop Entry] Type=Application Name=Jackify Comment=Wabbajack modlist manager for Linux @@ -85,10 +79,14 @@ Categories=Game;Utility; MimeType=x-scheme-handler/jackify; Path={src_dir} """ + if needs_write: desktop_file.write_text(desktop_content) logger.info("Desktop file written: %s", desktop_file) logger.info("Exec path: %s", exec_path) logger.info("AppImage mode: %s", is_appimage) + else: + logger.debug("Desktop file up to date, skipping write") + logger.info("Registering jackify:// protocol handler") apps_dir = Path.home() / ".local" / "share" / "applications" subprocess.run(['update-desktop-database', str(apps_dir)], capture_output=True, timeout=10) diff --git a/jackify/backend/services/steam_restart_service.py b/jackify/backend/services/steam_restart_service.py index b90ed6d..cb5e84e 100644 --- a/jackify/backend/services/steam_restart_service.py +++ b/jackify/backend/services/steam_restart_service.py @@ -673,6 +673,7 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No final_check = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=start_env) if final_check.returncode == 0: report("Steam started successfully.") + report("[Jackify] Steam restart complete") logger.info(f"Steam confirmed running after {elapsed_wait}s wait.") return True else: diff --git a/jackify/backend/services/ttw_installer_service.py b/jackify/backend/services/ttw_installer_service.py new file mode 100644 index 0000000..5f72af2 --- /dev/null +++ b/jackify/backend/services/ttw_installer_service.py @@ -0,0 +1,62 @@ +"""Shared backend helpers for locating and installing TTW_Linux_Installer.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Callable, Optional, Tuple + +from jackify.backend.handlers.config_handler import ConfigHandler +from jackify.backend.handlers.filesystem_handler import FileSystemHandler +from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler + +logger = logging.getLogger(__name__) + + +def _build_handler() -> TTWInstallerHandler: + return TTWInstallerHandler( + steamdeck=False, + verbose=False, + filesystem_handler=FileSystemHandler(), + config_handler=ConfigHandler(), + ) + + +def get_ttw_installer_path() -> Optional[Path]: + """Return the resolved TTW_Linux_Installer executable path, if available.""" + handler = _build_handler() + path = handler.ttw_installer_executable_path + if path and path.exists(): + return path + return None + + +def ensure_ttw_installer_available( + progress_callback: Optional[Callable[[str], None]] = None, +) -> Tuple[Optional[Path], str]: + """ + Ensure TTW_Linux_Installer is installed and return its executable path. + + Returns: + (path, message) + """ + existing = get_ttw_installer_path() + if existing: + return existing, "TTW_Linux_Installer ready" + + if progress_callback: + progress_callback("TTW_Linux_Installer not found, installing...") + + handler = _build_handler() + success, message = handler.install_ttw_installer() + if not success: + logger.error("Failed to install TTW_Linux_Installer: %s", message) + return None, message + + path = handler.ttw_installer_executable_path + if path and path.exists(): + if progress_callback: + progress_callback("TTW_Linux_Installer installed successfully") + return path, message + + return None, "TTW_Linux_Installer install completed but executable was not found" diff --git a/jackify/backend/services/update_service.py b/jackify/backend/services/update_service.py index 09bc560..211fc8f 100644 --- a/jackify/backend/services/update_service.py +++ b/jackify/backend/services/update_service.py @@ -101,13 +101,15 @@ class UpdateService: break if download_url: - # Prefer Nexus CDN for Premium users when release embeds nexus_file_id - release_body = release_data.get('body', '') - nexus_url = self._try_nexus_download_url(release_body) + # Prefer Nexus CDN for Premium users if this version is available there + nexus_url = self._try_nexus_download_url(latest_version) update_source = "github" if nexus_url: download_url = nexus_url update_source = "nexus" + logger.debug(f"UPD-1001 update_source_selected source=nexus version={latest_version}") + else: + logger.debug(f"UPD-1001 update_source_selected source=github version={latest_version}") # Determine if this is a delta update is_delta = '.delta' in download_url or 'delta' in download_url.lower() @@ -152,54 +154,69 @@ class UpdateService: logger.error(f"Unexpected error checking for updates: {e}") return None - def _try_nexus_download_url(self, release_body: str) -> Optional[str]: - """ - If the user is Nexus Premium and the release body embeds nexus_file_id, - return a Nexus CDN download URL. Returns None on any failure. + _NEXUS_MOD_ID = 1427 - Release body format expected: - nexus_mod_id: 12345 - nexus_file_id: 67890 + def _try_nexus_download_url(self, target_version: str) -> Optional[str]: + """ + If the user is Nexus Premium, query the Nexus files list for the mod + 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. """ - import re try: - mod_match = re.search(r'nexus_mod_id:\s*(\d+)', release_body, re.IGNORECASE) - file_match = re.search(r'nexus_file_id:\s*(\d+)', release_body, re.IGNORECASE) - if not file_match: - return None - nexus_file_id = int(file_match.group(1)) - nexus_mod_id = int(mod_match.group(1)) if mod_match else None - from jackify.backend.services.nexus_auth_service import NexusAuthService auth_service = NexusAuthService() token = auth_service.get_auth_token() if not token: + logger.debug("UPD-1002 nexus_lookup_skipped reason=missing_auth_token") return None + auth_method = auth_service.get_auth_method() + is_oauth = auth_method == "oauth" from jackify.backend.services.nexus_premium_service import NexusPremiumService - is_premium, _ = NexusPremiumService().check_premium_status(token) + is_premium, _ = NexusPremiumService().check_premium_status(token, is_oauth=is_oauth) if not is_premium: - logger.debug("Nexus download skipped: user is not Premium") + logger.debug("UPD-1002 nexus_lookup_skipped reason=not_premium") return None - if nexus_mod_id is None: + auth_headers = {"Accept": "application/json"} + if is_oauth: + auth_headers["Authorization"] = f"Bearer {token}" + else: + auth_headers["apikey"] = token + + files_url = f"https://api.nexusmods.com/v1/games/site/mods/{self._NEXUS_MOD_ID}/files.json" + resp = requests.get(files_url, headers=auth_headers, timeout=8) + resp.raise_for_status() + files = resp.json().get("files", []) + + # Prefer MAIN category; accept any non-archived/removed file matching the version. + match = None + for f in files: + if f.get("version") != target_version: + continue + if f.get("category_name") == "MAIN": + match = f + break + if f.get("category_name") not in ("ARCHIVED", "REMOVED"): + match = match or f + + if match is None: + logger.debug(f"UPD-1002 nexus_lookup_skipped reason=version_not_on_nexus version={target_version}") return None - api_url = f"https://api.nexusmods.com/v1/games/site/mods/{nexus_mod_id}/files/{nexus_file_id}/download_link.json" - resp = requests.get( - api_url, - headers={"apikey": token, "Accept": "application/json"}, - timeout=8, - ) + nexus_file_id = match["file_id"] + dl_url = f"https://api.nexusmods.com/v1/games/site/mods/{self._NEXUS_MOD_ID}/files/{nexus_file_id}/download_link.json" + resp = requests.get(dl_url, headers=auth_headers, timeout=8) resp.raise_for_status() links = resp.json() if isinstance(links, list) and links: cdn_url = links[0].get("URI") if cdn_url: - logger.debug(f"Using Nexus CDN URL for update") + logger.debug(f"UPD-1003 nexus_lookup_success file_id={nexus_file_id} version={target_version}") return cdn_url + logger.debug("UPD-1002 nexus_lookup_skipped reason=empty_download_links") except Exception as e: - logger.debug(f"Nexus download URL lookup failed: {e}") + logger.debug(f"UPD-1004 nexus_lookup_failed error={e}") return None def _is_newer_version(self, version: str) -> bool: @@ -494,4 +511,4 @@ rm -f "{helper_script}" except Exception as e: logger.error(f"Failed to create update helper script: {e}") - return None \ No newline at end of file + return None diff --git a/jackify/backend/services/vnv_post_install_service.py b/jackify/backend/services/vnv_post_install_service.py index fdc381b..1066abf 100644 --- a/jackify/backend/services/vnv_post_install_service.py +++ b/jackify/backend/services/vnv_post_install_service.py @@ -13,6 +13,7 @@ Uses native Linux tools (no Wine required) by downloading from Nexus with OAuth. import logging import os +import json import shutil import subprocess import stat @@ -83,6 +84,110 @@ class VNVPostInstallService: self.download_service = NexusDownloadService(auth_token) return True + def _ensure_download_service(self, progress_callback: Optional[Callable[[str], None]] = None) -> bool: + if self.download_service is not None: + return True + return self._ensure_auth(progress_callback) + + def _find_cached_4gb_patcher(self) -> Optional[Path]: + for path in self.cache_dir.iterdir(): + if path.is_file() and path.suffix.lower() == ".zip" and "4gb" in path.name.lower(): + return path + for path in self.cache_dir.iterdir(): + if path.is_dir() and path.name.lower().endswith("_extracted") and "4gb" in path.name.lower(): + for child in path.iterdir(): + if child.is_file(): + return child + return None + + def _find_cached_bsa_mpi(self) -> Optional[Path]: + for path in self.cache_dir.iterdir(): + if path.is_file() and path.suffix.lower() == ".mpi" and "bsa" in path.name.lower(): + return path + for path in self.cache_dir.iterdir(): + if path.is_dir() and path.name.lower().endswith("_extracted") and "bsa" in path.name.lower(): + for child in path.rglob("*.mpi"): + if child.is_file(): + return child + return None + + def _find_cached_bsa_package(self) -> Optional[Path]: + preferred = [] + fallback = [] + for path in self.cache_dir.iterdir(): + if not path.is_file(): + continue + lower = path.name.lower() + if "bsa" not in lower or path.suffix.lower() not in {".zip", ".7z"}: + continue + if path.suffix.lower() == ".zip": + preferred.append(path) + else: + fallback.append(path) + candidates = sorted(preferred) or sorted(fallback) + return candidates[0] if candidates else None + + def _extract_bsa_package(self, archive_path: Path) -> tuple[bool, Optional[Path], str]: + extract_dir = self.cache_dir / f"{archive_path.stem}_extracted" + mpi_path = next((p for p in extract_dir.rglob("*.mpi") if p.is_file()), None) if extract_dir.exists() else None + if mpi_path: + return True, mpi_path, f"Using extracted BSA package from {archive_path.name}" + + extract_dir.mkdir(parents=True, exist_ok=True) + try: + suffix = archive_path.suffix.lower() + if suffix == ".zip": + with zipfile.ZipFile(archive_path, 'r') as zip_ref: + zip_ref.extractall(extract_dir) + elif suffix == ".7z": + result = subprocess.run( + ["7z", "x", "-y", f"-o{extract_dir}", str(archive_path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return False, None, (result.stderr or result.stdout or "7z extraction failed").strip() + else: + return False, None, f"Unsupported BSA package format: {archive_path.name}" + except Exception as e: + return False, None, str(e) + + mpi_path = next((p for p in extract_dir.rglob("*.mpi") if p.is_file()), None) + if not mpi_path: + return False, None, f"No .mpi file found in BSA package: {archive_path.name}" + return True, mpi_path, f"Extracted BSA package {archive_path.name}" + + @staticmethod + def _select_manual_download_file(files: list[dict], mod_id: int) -> Optional[dict]: + def _active(entries: list[dict]) -> list[dict]: + return [f for f in entries if f.get("category_name") not in ("ARCHIVED", "REMOVED")] + + active_files = _active(files) + if mod_id == VNVPostInstallService.LINUX_4GB_PATCHER_MOD_ID: + proton_files = [ + f for f in active_files + if "proton" in f.get("file_name", "").lower() and f.get("file_name", "").lower().endswith(".zip") + ] + if proton_files: + proton_files.sort(key=lambda f: f.get("uploaded_timestamp", 0), reverse=True) + return proton_files[0] + if mod_id == VNVPostInstallService.FNV_BSA_DECOMPRESSOR_MOD_ID: + zip_files = [f for f in active_files if f.get("file_name", "").lower().endswith(".zip")] + if zip_files: + zip_files.sort(key=lambda f: f.get("uploaded_timestamp", 0), reverse=True) + return zip_files[0] + + main_files = [f for f in active_files if f.get("category_name") == "MAIN"] + if main_files: + main_files.sort(key=lambda f: f.get("uploaded_timestamp", 0), reverse=True) + return main_files[0] + + if active_files: + active_files.sort(key=lambda f: f.get("uploaded_timestamp", 0), reverse=True) + return active_files[0] + return None + def should_run_automation(self, modlist_name: str) -> bool: """ Check if this modlist should trigger VNV automation. @@ -108,11 +213,60 @@ class VNVPostInstallService: "1. Copy root mods to game directory\n" "2. Download and run Linux 4GB patcher\n" "3. Download and run BSA decompressor (reduces loading times)\n\n" - "Premium users: Downloads happen automatically\n" - "Non-Premium users: You'll be prompted to download files manually\n\n" + "Jackify will download the required tools automatically where possible.\n" + "If you are not a Nexus Premium member, you will be prompted to\n" + "manually download any tools that cannot be fetched automatically.\n\n" "Would you like Jackify to automate these steps?" ) + def get_manual_download_items(self, include_bsa: bool = False) -> list: + """ + Query Nexus for the current MAIN file of each required VNV tool and return + a list of DownloadItem-compatible event dicts for use with ManualDownloadManager. + Works with any Nexus auth (not Premium-only). + Returns an empty list if auth is unavailable or queries fail. + """ + import requests as _requests + token = self.auth_service.get_auth_token() + if not token: + return [] + auth_method = self.auth_service.get_auth_method() + headers = {"Accept": "application/json"} + if auth_method == "oauth": + headers["Authorization"] = f"Bearer {token}" + else: + headers["apikey"] = token + + tools = [(self.LINUX_4GB_PATCHER_MOD_ID, "4GB Patcher")] + if include_bsa: + tools.append((self.FNV_BSA_DECOMPRESSOR_MOD_ID, "BSA Decompressor")) + items = [] + for mod_id, label in tools: + try: + resp = _requests.get( + f"https://api.nexusmods.com/v1/games/newvegas/mods/{mod_id}/files.json", + headers=headers, timeout=8, + ) + resp.raise_for_status() + files = resp.json().get("files", []) + match = self._select_manual_download_file(files, mod_id) + if match is None: + logger.warning(f"VNV tool lookup: no suitable file found for mod {mod_id} ({label})") + continue + file_id = match["file_id"] + items.append({ + "file_name": match["file_name"], + "mod_name": label, + "nexus_url": f"https://www.nexusmods.com/newvegas/mods/{mod_id}?tab=files&file_id={file_id}", + "expected_hash": "", + "expected_size": match.get("size_kb", 0) * 1024, + "mod_id": mod_id, + "file_id": file_id, + }) + except Exception as e: + logger.warning(f"VNV tool lookup failed for mod {mod_id} ({label}): {e}") + return items + def check_already_completed(self) -> dict: """ Check which VNV automation steps have already been completed. @@ -158,11 +312,6 @@ class VNVPostInstallService: logger.info(msg) try: - # Ensure authentication - update_progress("Checking Nexus authentication...") - if not self._ensure_auth(progress_callback): - return False, "Nexus authentication required. Please authenticate in Settings." - # Step 1: Copy root mods update_progress("Step 1/3: Copying root mods to game directory...") success, msg = self.copy_root_mods() @@ -253,25 +402,15 @@ class VNVPostInstallService: return True, "Game already patched (backup exists)" # Check cache first - look for extracted executable or zip - patcher_path = None - cached_extracted = list(self.cache_dir.glob("*4gb*_extracted/*")) - if cached_extracted: - # Use already extracted executable - for f in cached_extracted: - if f.is_file(): - patcher_path = f - logger.info(f"Using cached extracted 4GB patcher: {patcher_path}") - break - - if not patcher_path: - cached_files = list(self.cache_dir.glob("*4gb*.zip")) - if cached_files: - patcher_path = cached_files[0] - logger.info(f"Using cached 4GB patcher zip: {patcher_path}") + patcher_path = self._find_cached_4gb_patcher() + if patcher_path: + logger.info(f"Using cached 4GB patcher: {patcher_path}") if not patcher_path: # Try to download from Nexus # Linux version is named "FNV4GB for Proton", not "linux" + if not self._ensure_download_service(progress_callback): + return False, "Nexus authentication required to download the 4GB patcher." success, patcher_path, msg = self.download_service.download_latest_file( self.GAME_DOMAIN, self.LINUX_4GB_PATCHER_MOD_ID, @@ -394,60 +533,58 @@ class VNVPostInstallService: return True, "BSA decompression already completed" if not self.ttw_installer_path or not self.ttw_installer_path.exists(): - logger.warning("TTW_Linux_Installer not found, skipping BSA decompression") - return True, "BSA decompression skipped (TTW_Linux_Installer not available)" + from .ttw_installer_service import ensure_ttw_installer_available - # Check cache first - cached_files = list(self.cache_dir.glob("*BSA*.mpi")) - if cached_files: - mpi_path = cached_files[0] + self.ttw_installer_path, message = ensure_ttw_installer_available(progress_callback) + if not self.ttw_installer_path: + return False, f"TTW_Linux_Installer is required for BSA decompression: {message}" + + mpi_path = self._find_cached_bsa_mpi() + if mpi_path: logger.info(f"Using cached BSA Decompressor MPI: {mpi_path}") else: - # Also check for exact filename match (handles spaces in filename) - exact_path = self.cache_dir / "FNV BSA Decompressor.mpi" - if exact_path.exists(): - mpi_path = exact_path - logger.info(f"Using cached BSA Decompressor MPI: {mpi_path}") - else: - # Try to download from Nexus - # Look for files with .mpi extension (TTW installer format) - success, mpi_path, msg = self.download_service.download_latest_file( + package_path = self._find_cached_bsa_package() + if not package_path: + if not self._ensure_download_service(progress_callback): + return False, "Nexus authentication required to download the BSA Decompressor." + success, package_path, msg = self.download_service.download_latest_file( self.GAME_DOMAIN, self.FNV_BSA_DECOMPRESSOR_MOD_ID, self.cache_dir, - file_name_filter=".mpi", + file_name_filter=".zip", progress_callback=progress_callback ) - if not success: - # Download failed - offer manual download logger.warning(f"Automatic download failed: {msg}") - if not manual_file_callback: - return False, f"Failed to download BSA Decompressor MPI: {msg}\n\nPlease download manually from: https://www.nexusmods.com/newvegas/mods/65854" + return False, f"Failed to download BSA Decompressor package: {msg}\n\nPlease download manually from: https://www.nexusmods.com/newvegas/mods/65854" instructions = ( "Automatic download failed (requires Nexus Premium).\n\n" - "Please download the FNV BSA Decompressor manually:\n" + "Please download the FNV BSA Decompressor package manually:\n" "1. Visit: https://www.nexusmods.com/newvegas/mods/65854\n" - "2. Download the .mpi file\n" - "3. Select the downloaded file below" + "2. Download the zip package\n" + "3. Select the downloaded archive below" ) + selected_path = manual_file_callback("BSA Decompressor Required", instructions) + if not selected_path or not selected_path.exists(): + return False, "BSA Decompressor package not provided" + if selected_path.suffix.lower() not in {'.zip', '.7z', '.mpi'}: + return False, f"Selected file is not a supported BSA package: {selected_path}" + cached_path = self.cache_dir / selected_path.name + shutil.copy2(selected_path, cached_path) + package_path = cached_path + logger.info(f"Using manually selected BSA package: {package_path}") - mpi_path = manual_file_callback("BSA Decompressor Required", instructions) - - if not mpi_path or not mpi_path.exists(): - return False, "BSA Decompressor MPI file not provided" - - # Validate it's an MPI file - if not mpi_path.suffix.lower() == '.mpi': - return False, f"Selected file is not an MPI file: {mpi_path}" - - # Copy to cache for future use - cached_path = self.cache_dir / mpi_path.name - shutil.copy2(mpi_path, cached_path) - mpi_path = cached_path - logger.info(f"Using manually selected BSA Decompressor MPI: {mpi_path}") + if package_path.suffix.lower() == ".mpi": + mpi_path = package_path + else: + if progress_callback: + progress_callback("Preparing BSA decompressor package...") + success, mpi_path, msg = self._extract_bsa_package(package_path) + if not success or not mpi_path: + return False, f"Failed to prepare BSA Decompressor package: {msg}" + logger.info(msg) # Create temp output directory with tempfile.TemporaryDirectory() as temp_output: @@ -455,7 +592,6 @@ class VNVPostInstallService: # Create config file for TTW_Linux_Installer (handles spaces in paths better) config_file = self.ttw_installer_path.parent / "ttw-config.json" - import json config_data = { "FalloutNVRoot": str(self.game_root), "MpiPackagePath": str(mpi_path), @@ -467,6 +603,7 @@ class VNVPostInstallService: # Run via TTW_Linux_Installer if progress_callback: + progress_callback("Ensuring TTW_Linux_Installer is available...") progress_callback("Running BSA decompressor...") cmd = [ diff --git a/jackify/backend/services/wabbajack_installer_service.py b/jackify/backend/services/wabbajack_installer_service.py index 2e02357..4a3190b 100644 --- a/jackify/backend/services/wabbajack_installer_service.py +++ b/jackify/backend/services/wabbajack_installer_service.py @@ -63,6 +63,7 @@ class WabbajackInstallerService: install_folder: Path, shortcut_name: str = "Wabbajack", enable_gog: bool = True, + existing_appid: Optional[int] = None, progress_callback: Optional[Callable[[str, int], None]] = None, log_callback: Optional[Callable[[str], None]] = None ) -> Tuple[bool, Optional[int], Optional[str], Optional[int], Optional[str], Optional[str]]: @@ -128,34 +129,6 @@ class WabbajackInstallerService: self.handler.create_dotnet_cache(install_folder) update_progress(".NET cache created", 3, 20) - # Step 4: Stop Steam briefly (required to safely modify shortcuts.vdf) - # We'll do a full restart after creating the shortcut - update_progress("Stopping Steam to modify shortcuts...", 4, 25) - try: - shutdown_env = _get_clean_subprocess_env() - - if _is_steam_deck: - subprocess.run(['systemctl', '--user', 'stop', 'app-steam@autostart.service'], - timeout=15, check=False, capture_output=True, env=shutdown_env) - elif _is_flatpak: - subprocess.run(['flatpak', 'kill', 'com.valvesoftware.Steam'], - timeout=15, check=False, capture_output=True, env=shutdown_env) - - subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env) - time.sleep(2) - - check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env) - if check_result.returncode == 0: - subprocess.run(['pkill', '-9', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env) - time.sleep(2) - - update_progress("Steam stopped", 4, 25) - except Exception as e: - update_progress(f"Warning: Steam shutdown had issues: {e}. Proceeding anyway...", 4, 25) - - # Step 5: Create Steam shortcut using NativeSteamService - update_progress("Adding Wabbajack to Steam shortcuts...", 5, 30) - # Generate launch options with STEAM_COMPAT_MOUNTS launch_options = "" try: @@ -170,27 +143,58 @@ class WabbajackInstallerService: except Exception as e: update_progress(f"Could not generate STEAM_COMPAT_MOUNTS (non-critical): {e}", 5, 30) - success, app_id = self.steam_service.create_shortcut_with_proton( - app_name=shortcut_name, - exe_path=str(wabbajack_exe), - start_dir=str(wabbajack_exe.parent), - launch_options=launch_options, - tags=["Jackify"], - proton_version=proton_compat_name - ) - if not success or app_id is None: - return False, None, None, None, None, "Failed to create Steam shortcut" - update_progress(f"Created Steam shortcut with AppID: {app_id}", 5, 30) + if existing_appid is None: + # Step 4: Stop Steam briefly (required to safely modify shortcuts.vdf) + # We'll do a full restart after creating the shortcut + update_progress("Stopping Steam to modify shortcuts...", 4, 25) + try: + shutdown_env = _get_clean_subprocess_env() - # Step 5b: Restart Steam (same pattern as modlist workflows) - update_progress("Restarting Steam...", 5, 35) - def restart_callback(msg): - update_progress(msg, 5, 35) + if _is_steam_deck: + subprocess.run(['systemctl', '--user', 'stop', 'app-steam@autostart.service'], + timeout=15, check=False, capture_output=True, env=shutdown_env) + elif _is_flatpak: + subprocess.run(['flatpak', 'kill', 'com.valvesoftware.Steam'], + timeout=15, check=False, capture_output=True, env=shutdown_env) - if not robust_steam_restart(progress_callback=restart_callback): - update_progress("Warning: Steam restart had issues, continuing anyway...", 5, 35) + subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env) + time.sleep(2) + + check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env) + if check_result.returncode == 0: + subprocess.run(['pkill', '-9', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env) + time.sleep(2) + + update_progress("Steam stopped", 4, 25) + except Exception as e: + update_progress(f"Warning: Steam shutdown had issues: {e}. Proceeding anyway...", 4, 25) + + # Step 5: Create Steam shortcut using NativeSteamService + update_progress("Adding Wabbajack to Steam shortcuts...", 5, 30) + success, app_id = self.steam_service.create_shortcut_with_proton( + app_name=shortcut_name, + exe_path=str(wabbajack_exe), + start_dir=str(wabbajack_exe.parent), + launch_options=launch_options, + tags=["Jackify"], + proton_version=proton_compat_name + ) + if not success or app_id is None: + return False, None, None, None, None, "Failed to create Steam shortcut" + update_progress(f"Created Steam shortcut with AppID: {app_id}", 5, 30) + + # Step 5b: Restart Steam (same pattern as modlist workflows) + update_progress("Restarting Steam...", 5, 35) + def restart_callback(msg): + update_progress(msg, 5, 35) + + if not robust_steam_restart(progress_callback=restart_callback): + update_progress("Warning: Steam restart had issues, continuing anyway...", 5, 35) + else: + update_progress("Steam restarted successfully", 5, 40) else: - update_progress("Steam restarted successfully", 5, 40) + app_id = int(existing_appid) + update_progress(f"Reusing existing Steam shortcut with AppID: {app_id}", 5, 30) # Step 6: Initialize Wine prefix (using same method as modlist workflows) update_progress("Creating Proton prefix...", 6, 45) @@ -277,4 +281,3 @@ class WabbajackInstallerService: if log_callback: log_callback(f"ERROR: {error_msg}") return False, None, None, None, None, error_msg - diff --git a/jackify/backend/utils/cc_content_detector.py b/jackify/backend/utils/cc_content_detector.py new file mode 100644 index 0000000..2f2ce6b --- /dev/null +++ b/jackify/backend/utils/cc_content_detector.py @@ -0,0 +1,34 @@ +""" +Detects Creation Club / Anniversary Edition content missing errors in engine output. +""" + +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-...) +# 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)', + re.IGNORECASE +) + +_ERROR_WORDS = frozenset(( + 'missing', 'required', 'failed', 'unable', 'cannot', 'error', 'not found', +)) + + +def is_cc_content_error(line: str) -> bool: + """Return True if line indicates a missing CC/AE content file in an error context.""" + if not line: + return False + normalized = line.strip().lower() + if not _CC_FILE_RE.search(normalized): + return False + return any(w in normalized for w in _ERROR_WORDS) + + +def extract_cc_filename(line: str) -> Optional[str]: + """Return the CC filename from a line, or None if not found.""" + m = _CC_FILE_RE.search(line) + return m.group(0) if m else None diff --git a/jackify/backend/utils/engine_error_parser.py b/jackify/backend/utils/engine_error_parser.py index 53c4d81..ab1613b 100644 --- a/jackify/backend/utils/engine_error_parser.py +++ b/jackify/backend/utils/engine_error_parser.py @@ -1,8 +1,10 @@ import json +import re from typing import Optional from jackify.shared.errors import ( JackifyError, InstallError, OAuthError, oauth_expired, wabbajack_install_failed, format_technical_context, + game_not_found_for_modlist, ) @@ -12,7 +14,29 @@ def _ctx_detail(ctx: dict) -> Optional[str]: return format_technical_context(context=ctx) +def _engine_error(msg: str, ctx: dict) -> InstallError: + """Map generic engine_error payloads to user-visible, actionable InstallError variants.""" + text = (msg or "").strip() + match = re.search(r"can't find game\s+([A-Za-z0-9_:-]+)", text, flags=re.IGNORECASE) + if match: + game_name = match.group(1) + return game_not_found_for_modlist(game_name, detail=text) + + return InstallError( + "Install Engine Error", + text or "An install engine error occurred.", + suggestion="Review the error message and retry after correcting the reported issue.", + solutions=[ + "Check the exact error message shown above and fix the prerequisite it mentions.", + "Retry the install after restarting Steam.", + "If this persists, check Modlist_Install_workflow.log for the same error text.", + ], + technical=_ctx_detail(ctx), + ) + + _TYPE_MAP = { + "engine_error": _engine_error, "auth_failed": lambda msg, ctx: oauth_expired(), "premium_required": lambda msg, ctx: InstallError( "Nexus Premium Required", diff --git a/jackify/engine/Wabbajack.CLI.Builder.dll b/jackify/engine/Wabbajack.CLI.Builder.dll index 4594648..3663399 100644 Binary files a/jackify/engine/Wabbajack.CLI.Builder.dll and b/jackify/engine/Wabbajack.CLI.Builder.dll differ diff --git a/jackify/engine/Wabbajack.Common.dll b/jackify/engine/Wabbajack.Common.dll index 6bd9eb9..1153b50 100644 Binary files a/jackify/engine/Wabbajack.Common.dll and b/jackify/engine/Wabbajack.Common.dll differ diff --git a/jackify/engine/Wabbajack.Compiler.dll b/jackify/engine/Wabbajack.Compiler.dll index 6931ba8..5d1be7c 100644 Binary files a/jackify/engine/Wabbajack.Compiler.dll and b/jackify/engine/Wabbajack.Compiler.dll differ diff --git a/jackify/engine/Wabbajack.Compression.BSA.dll b/jackify/engine/Wabbajack.Compression.BSA.dll index 387a203..54716fd 100644 Binary files a/jackify/engine/Wabbajack.Compression.BSA.dll and b/jackify/engine/Wabbajack.Compression.BSA.dll differ diff --git a/jackify/engine/Wabbajack.Compression.Zip.dll b/jackify/engine/Wabbajack.Compression.Zip.dll index cf5f03d..55ed1bf 100644 Binary files a/jackify/engine/Wabbajack.Compression.Zip.dll and b/jackify/engine/Wabbajack.Compression.Zip.dll differ diff --git a/jackify/engine/Wabbajack.Configuration.dll b/jackify/engine/Wabbajack.Configuration.dll index 005de8d..dd09668 100644 Binary files a/jackify/engine/Wabbajack.Configuration.dll and b/jackify/engine/Wabbajack.Configuration.dll differ diff --git a/jackify/engine/Wabbajack.DTOs.dll b/jackify/engine/Wabbajack.DTOs.dll index 9c5581f..7827d81 100644 Binary files a/jackify/engine/Wabbajack.DTOs.dll and b/jackify/engine/Wabbajack.DTOs.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Bethesda.dll b/jackify/engine/Wabbajack.Downloaders.Bethesda.dll index e926c84..06a343f 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Bethesda.dll and b/jackify/engine/Wabbajack.Downloaders.Bethesda.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll b/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll index f7a38c0..a915cfa 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll and b/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.GameFile.dll b/jackify/engine/Wabbajack.Downloaders.GameFile.dll index a54cf0f..8b76c64 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.GameFile.dll and b/jackify/engine/Wabbajack.Downloaders.GameFile.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll b/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll index 95d808b..362edb3 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll and b/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Http.dll b/jackify/engine/Wabbajack.Downloaders.Http.dll index 61b33cb..0910771 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Http.dll and b/jackify/engine/Wabbajack.Downloaders.Http.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll b/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll index 40cad4e..3a18af1 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll and b/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Interfaces.dll b/jackify/engine/Wabbajack.Downloaders.Interfaces.dll index 29bebb9..34fa9a7 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Interfaces.dll and b/jackify/engine/Wabbajack.Downloaders.Interfaces.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Manual.dll b/jackify/engine/Wabbajack.Downloaders.Manual.dll index a55ff84..fef16a0 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Manual.dll and b/jackify/engine/Wabbajack.Downloaders.Manual.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.MediaFire.dll b/jackify/engine/Wabbajack.Downloaders.MediaFire.dll index f57b1ee..105b340 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.MediaFire.dll and b/jackify/engine/Wabbajack.Downloaders.MediaFire.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Mega.dll b/jackify/engine/Wabbajack.Downloaders.Mega.dll index 4a10116..4feebb7 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Mega.dll and b/jackify/engine/Wabbajack.Downloaders.Mega.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.ModDB.dll b/jackify/engine/Wabbajack.Downloaders.ModDB.dll index 51609f6..4b81979 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.ModDB.dll and b/jackify/engine/Wabbajack.Downloaders.ModDB.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Nexus.dll b/jackify/engine/Wabbajack.Downloaders.Nexus.dll index 0cc552f..780f08e 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Nexus.dll and b/jackify/engine/Wabbajack.Downloaders.Nexus.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll b/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll index 0ff8b8a..4c80e62 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll and b/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll b/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll index 2368fa0..7cd968b 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll and b/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll differ diff --git a/jackify/engine/Wabbajack.FileExtractor.dll b/jackify/engine/Wabbajack.FileExtractor.dll index e825b62..040f8f4 100644 Binary files a/jackify/engine/Wabbajack.FileExtractor.dll and b/jackify/engine/Wabbajack.FileExtractor.dll differ diff --git a/jackify/engine/Wabbajack.Hashing.PHash.dll b/jackify/engine/Wabbajack.Hashing.PHash.dll index bf18fec..8ca590a 100644 Binary files a/jackify/engine/Wabbajack.Hashing.PHash.dll and b/jackify/engine/Wabbajack.Hashing.PHash.dll differ diff --git a/jackify/engine/Wabbajack.Hashing.xxHash64.dll b/jackify/engine/Wabbajack.Hashing.xxHash64.dll index 20e6c5b..e606505 100644 Binary files a/jackify/engine/Wabbajack.Hashing.xxHash64.dll and b/jackify/engine/Wabbajack.Hashing.xxHash64.dll differ diff --git a/jackify/engine/Wabbajack.IO.Async.dll b/jackify/engine/Wabbajack.IO.Async.dll index c38169a..7ad47ea 100644 Binary files a/jackify/engine/Wabbajack.IO.Async.dll and b/jackify/engine/Wabbajack.IO.Async.dll differ diff --git a/jackify/engine/Wabbajack.Installer.dll b/jackify/engine/Wabbajack.Installer.dll index fee334c..7d60b3e 100644 Binary files a/jackify/engine/Wabbajack.Installer.dll and b/jackify/engine/Wabbajack.Installer.dll differ diff --git a/jackify/engine/Wabbajack.Networking.BethesdaNet.dll b/jackify/engine/Wabbajack.Networking.BethesdaNet.dll index 4783fda..b1725b5 100644 Binary files a/jackify/engine/Wabbajack.Networking.BethesdaNet.dll and b/jackify/engine/Wabbajack.Networking.BethesdaNet.dll differ diff --git a/jackify/engine/Wabbajack.Networking.Discord.dll b/jackify/engine/Wabbajack.Networking.Discord.dll index c6b12f4..744aafc 100644 Binary files a/jackify/engine/Wabbajack.Networking.Discord.dll and b/jackify/engine/Wabbajack.Networking.Discord.dll differ diff --git a/jackify/engine/Wabbajack.Networking.GitHub.dll b/jackify/engine/Wabbajack.Networking.GitHub.dll index ca45db9..502cae9 100644 Binary files a/jackify/engine/Wabbajack.Networking.GitHub.dll and b/jackify/engine/Wabbajack.Networking.GitHub.dll differ diff --git a/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll b/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll index 1573515..27ea398 100644 Binary files a/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll and b/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll differ diff --git a/jackify/engine/Wabbajack.Networking.Http.dll b/jackify/engine/Wabbajack.Networking.Http.dll index 2b4783a..1e00078 100644 Binary files a/jackify/engine/Wabbajack.Networking.Http.dll and b/jackify/engine/Wabbajack.Networking.Http.dll differ diff --git a/jackify/engine/Wabbajack.Networking.NexusApi.dll b/jackify/engine/Wabbajack.Networking.NexusApi.dll index 8435fc0..fcdb487 100644 Binary files a/jackify/engine/Wabbajack.Networking.NexusApi.dll and b/jackify/engine/Wabbajack.Networking.NexusApi.dll differ diff --git a/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll b/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll index 8ed123b..9d4aa7d 100644 Binary files a/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll and b/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll differ diff --git a/jackify/engine/Wabbajack.Paths.IO.dll b/jackify/engine/Wabbajack.Paths.IO.dll index e15b759..74be9d3 100644 Binary files a/jackify/engine/Wabbajack.Paths.IO.dll and b/jackify/engine/Wabbajack.Paths.IO.dll differ diff --git a/jackify/engine/Wabbajack.Paths.dll b/jackify/engine/Wabbajack.Paths.dll index ffb9646..1a0a62a 100644 Binary files a/jackify/engine/Wabbajack.Paths.dll and b/jackify/engine/Wabbajack.Paths.dll differ diff --git a/jackify/engine/Wabbajack.RateLimiter.dll b/jackify/engine/Wabbajack.RateLimiter.dll index 32a3cd9..25c7185 100644 Binary files a/jackify/engine/Wabbajack.RateLimiter.dll and b/jackify/engine/Wabbajack.RateLimiter.dll differ diff --git a/jackify/engine/Wabbajack.Server.Lib.dll b/jackify/engine/Wabbajack.Server.Lib.dll index 9955c9e..4b44201 100644 Binary files a/jackify/engine/Wabbajack.Server.Lib.dll and b/jackify/engine/Wabbajack.Server.Lib.dll differ diff --git a/jackify/engine/Wabbajack.Services.OSIntegrated.dll b/jackify/engine/Wabbajack.Services.OSIntegrated.dll index ebffed5..257db89 100644 Binary files a/jackify/engine/Wabbajack.Services.OSIntegrated.dll and b/jackify/engine/Wabbajack.Services.OSIntegrated.dll differ diff --git a/jackify/engine/Wabbajack.VFS.Interfaces.dll b/jackify/engine/Wabbajack.VFS.Interfaces.dll index 327e2bd..8c077ff 100644 Binary files a/jackify/engine/Wabbajack.VFS.Interfaces.dll and b/jackify/engine/Wabbajack.VFS.Interfaces.dll differ diff --git a/jackify/engine/Wabbajack.VFS.dll b/jackify/engine/Wabbajack.VFS.dll index 9d6a36f..11c0607 100644 Binary files a/jackify/engine/Wabbajack.VFS.dll and b/jackify/engine/Wabbajack.VFS.dll differ diff --git a/jackify/engine/jackify-engine.deps.json b/jackify/engine/jackify-engine.deps.json index 812caa4..4c3c478 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.4.8": { + "jackify-engine/0.5.0": { "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.4.8", - "Wabbajack.Downloaders.Bethesda": "0.4.8", - "Wabbajack.Downloaders.Dispatcher": "0.4.8", - "Wabbajack.Hashing.xxHash64": "0.4.8", - "Wabbajack.Networking.Discord": "0.4.8", - "Wabbajack.Networking.GitHub": "0.4.8", - "Wabbajack.Paths.IO": "0.4.8", - "Wabbajack.Server.Lib": "0.4.8", - "Wabbajack.Services.OSIntegrated": "0.4.8", - "Wabbajack.VFS": "0.4.8", + "Wabbajack.CLI.Builder": "0.5.0", + "Wabbajack.Downloaders.Bethesda": "0.5.0", + "Wabbajack.Downloaders.Dispatcher": "0.5.0", + "Wabbajack.Hashing.xxHash64": "0.5.0", + "Wabbajack.Networking.Discord": "0.5.0", + "Wabbajack.Networking.GitHub": "0.5.0", + "Wabbajack.Paths.IO": "0.5.0", + "Wabbajack.Server.Lib": "0.5.0", + "Wabbajack.Services.OSIntegrated": "0.5.0", + "Wabbajack.VFS": "0.5.0", "MegaApiClient": "1.0.0.0", "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.23" }, @@ -1781,7 +1781,7 @@ } } }, - "Wabbajack.CLI.Builder/0.4.8": { + "Wabbajack.CLI.Builder/0.5.0": { "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.4.8" + "Wabbajack.Paths": "0.5.0" }, "runtime": { "Wabbajack.CLI.Builder.dll": {} } }, - "Wabbajack.Common/0.4.8": { + "Wabbajack.Common/0.5.0": { "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.4.8", - "Wabbajack.Networking.Http": "0.4.8", - "Wabbajack.Paths.IO": "0.4.8" + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Networking.Http": "0.5.0", + "Wabbajack.Paths.IO": "0.5.0" }, "runtime": { "Wabbajack.Common.dll": {} } }, - "Wabbajack.Compiler/0.4.8": { + "Wabbajack.Compiler/0.5.0": { "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.4.8", - "Wabbajack.Installer": "0.4.8", - "Wabbajack.VFS": "0.4.8", + "Wabbajack.Downloaders.Dispatcher": "0.5.0", + "Wabbajack.Installer": "0.5.0", + "Wabbajack.VFS": "0.5.0", "ini-parser-netstandard": "2.5.2" }, "runtime": { "Wabbajack.Compiler.dll": {} } }, - "Wabbajack.Compression.BSA/0.4.8": { + "Wabbajack.Compression.BSA/0.5.0": { "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.4.8", - "Wabbajack.DTOs": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.DTOs": "0.5.0" }, "runtime": { "Wabbajack.Compression.BSA.dll": {} } }, - "Wabbajack.Compression.Zip/0.4.8": { + "Wabbajack.Compression.Zip/0.5.0": { "dependencies": { - "Wabbajack.IO.Async": "0.4.8" + "Wabbajack.IO.Async": "0.5.0" }, "runtime": { "Wabbajack.Compression.Zip.dll": {} } }, - "Wabbajack.Configuration/0.4.8": { + "Wabbajack.Configuration/0.5.0": { "runtime": { "Wabbajack.Configuration.dll": {} } }, - "Wabbajack.Downloaders.Bethesda/0.4.8": { + "Wabbajack.Downloaders.Bethesda/0.5.0": { "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.4.8", - "Wabbajack.Downloaders.Interfaces": "0.4.8", - "Wabbajack.Networking.BethesdaNet": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.Downloaders.Interfaces": "0.5.0", + "Wabbajack.Networking.BethesdaNet": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.Bethesda.dll": {} } }, - "Wabbajack.Downloaders.Dispatcher/0.4.8": { + "Wabbajack.Downloaders.Dispatcher/0.5.0": { "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.4.8", - "Wabbajack.Downloaders.GameFile": "0.4.8", - "Wabbajack.Downloaders.GoogleDrive": "0.4.8", - "Wabbajack.Downloaders.Http": "0.4.8", - "Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.4.8", - "Wabbajack.Downloaders.Interfaces": "0.4.8", - "Wabbajack.Downloaders.Manual": "0.4.8", - "Wabbajack.Downloaders.MediaFire": "0.4.8", - "Wabbajack.Downloaders.Mega": "0.4.8", - "Wabbajack.Downloaders.ModDB": "0.4.8", - "Wabbajack.Downloaders.Nexus": "0.4.8", - "Wabbajack.Downloaders.VerificationCache": "0.4.8", - "Wabbajack.Downloaders.WabbajackCDN": "0.4.8", - "Wabbajack.Networking.WabbajackClientApi": "0.4.8" + "Wabbajack.Downloaders.Bethesda": "0.5.0", + "Wabbajack.Downloaders.GameFile": "0.5.0", + "Wabbajack.Downloaders.GoogleDrive": "0.5.0", + "Wabbajack.Downloaders.Http": "0.5.0", + "Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.5.0", + "Wabbajack.Downloaders.Interfaces": "0.5.0", + "Wabbajack.Downloaders.Manual": "0.5.0", + "Wabbajack.Downloaders.MediaFire": "0.5.0", + "Wabbajack.Downloaders.Mega": "0.5.0", + "Wabbajack.Downloaders.ModDB": "0.5.0", + "Wabbajack.Downloaders.Nexus": "0.5.0", + "Wabbajack.Downloaders.VerificationCache": "0.5.0", + "Wabbajack.Downloaders.WabbajackCDN": "0.5.0", + "Wabbajack.Networking.WabbajackClientApi": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.Dispatcher.dll": {} } }, - "Wabbajack.Downloaders.GameFile/0.4.8": { + "Wabbajack.Downloaders.GameFile/0.5.0": { "dependencies": { "GameFinder.StoreHandlers.EADesktop": "4.5.0", "GameFinder.StoreHandlers.EGS": "4.5.0", @@ -1903,360 +1903,361 @@ "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Downloaders.Interfaces": "0.4.8", - "Wabbajack.VFS": "0.4.8" + "Wabbajack.Downloaders.Interfaces": "0.5.0", + "Wabbajack.VFS": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.GameFile.dll": {} } }, - "Wabbajack.Downloaders.GoogleDrive/0.4.8": { + "Wabbajack.Downloaders.GoogleDrive/0.5.0": { "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.4.8", - "Wabbajack.DTOs": "0.4.8", - "Wabbajack.Downloaders.Interfaces": "0.4.8", - "Wabbajack.Networking.Http": "0.4.8", - "Wabbajack.Networking.Http.Interfaces": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Downloaders.Interfaces": "0.5.0", + "Wabbajack.Networking.Http": "0.5.0", + "Wabbajack.Networking.Http.Interfaces": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.GoogleDrive.dll": {} } }, - "Wabbajack.Downloaders.Http/0.4.8": { + "Wabbajack.Downloaders.Http/0.5.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.4.8", - "Wabbajack.DTOs": "0.4.8", - "Wabbajack.Downloaders.Interfaces": "0.4.8", - "Wabbajack.Networking.BethesdaNet": "0.4.8", - "Wabbajack.Networking.Http.Interfaces": "0.4.8", - "Wabbajack.Paths.IO": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Downloaders.Interfaces": "0.5.0", + "Wabbajack.Networking.BethesdaNet": "0.5.0", + "Wabbajack.Networking.Http.Interfaces": "0.5.0", + "Wabbajack.Paths.IO": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.Http.dll": {} } }, - "Wabbajack.Downloaders.Interfaces/0.4.8": { + "Wabbajack.Downloaders.Interfaces/0.5.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.Compression.Zip": "0.4.8", - "Wabbajack.DTOs": "0.4.8", - "Wabbajack.Paths.IO": "0.4.8" + "Wabbajack.Compression.Zip": "0.5.0", + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Paths.IO": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.Interfaces.dll": {} } }, - "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.8": { + "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.5.0": { "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.4.8", - "Wabbajack.Downloaders.Interfaces": "0.4.8", - "Wabbajack.Networking.Http": "0.4.8", - "Wabbajack.Networking.Http.Interfaces": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.Downloaders.Interfaces": "0.5.0", + "Wabbajack.Networking.Http": "0.5.0", + "Wabbajack.Networking.Http.Interfaces": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {} } }, - "Wabbajack.Downloaders.Manual/0.4.8": { + "Wabbajack.Downloaders.Manual/0.5.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.4.8", - "Wabbajack.Downloaders.Interfaces": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.Downloaders.Interfaces": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.Manual.dll": {} } }, - "Wabbajack.Downloaders.MediaFire/0.4.8": { + "Wabbajack.Downloaders.MediaFire/0.5.0": { "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.4.8", - "Wabbajack.Downloaders.Interfaces": "0.4.8", - "Wabbajack.Networking.Http.Interfaces": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.Downloaders.Interfaces": "0.5.0", + "Wabbajack.Networking.Http.Interfaces": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.MediaFire.dll": {} } }, - "Wabbajack.Downloaders.Mega/0.4.8": { + "Wabbajack.Downloaders.Mega/0.5.0": { "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.4.8", - "Wabbajack.Downloaders.Interfaces": "0.4.8", - "Wabbajack.Paths.IO": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.Downloaders.Interfaces": "0.5.0", + "Wabbajack.Paths.IO": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.Mega.dll": {} } }, - "Wabbajack.Downloaders.ModDB/0.4.8": { + "Wabbajack.Downloaders.ModDB/0.5.0": { "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.4.8", - "Wabbajack.Downloaders.Interfaces": "0.4.8", - "Wabbajack.Networking.Http": "0.4.8", - "Wabbajack.Networking.Http.Interfaces": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.Downloaders.Interfaces": "0.5.0", + "Wabbajack.Networking.Http": "0.5.0", + "Wabbajack.Networking.Http.Interfaces": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.ModDB.dll": {} } }, - "Wabbajack.Downloaders.Nexus/0.4.8": { + "Wabbajack.Downloaders.Nexus/0.5.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.4.8", - "Wabbajack.Downloaders.Interfaces": "0.4.8", - "Wabbajack.Hashing.xxHash64": "0.4.8", - "Wabbajack.Networking.Http": "0.4.8", - "Wabbajack.Networking.Http.Interfaces": "0.4.8", - "Wabbajack.Networking.NexusApi": "0.4.8", - "Wabbajack.Paths": "0.4.8" + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Downloaders.Interfaces": "0.5.0", + "Wabbajack.Hashing.xxHash64": "0.5.0", + "Wabbajack.Networking.Http": "0.5.0", + "Wabbajack.Networking.Http.Interfaces": "0.5.0", + "Wabbajack.Networking.NexusApi": "0.5.0", + "Wabbajack.Paths": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.Nexus.dll": {} } }, - "Wabbajack.Downloaders.VerificationCache/0.4.8": { + "Wabbajack.Downloaders.VerificationCache/0.5.0": { "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.4.8", - "Wabbajack.Paths.IO": "0.4.8" + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Paths.IO": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.VerificationCache.dll": {} } }, - "Wabbajack.Downloaders.WabbajackCDN/0.4.8": { + "Wabbajack.Downloaders.WabbajackCDN/0.5.0": { "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.4.8", - "Wabbajack.Downloaders.Interfaces": "0.4.8", - "Wabbajack.Networking.Http": "0.4.8", - "Wabbajack.RateLimiter": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.Downloaders.Interfaces": "0.5.0", + "Wabbajack.Networking.Http": "0.5.0", + "Wabbajack.RateLimiter": "0.5.0" }, "runtime": { "Wabbajack.Downloaders.WabbajackCDN.dll": {} } }, - "Wabbajack.DTOs/0.4.8": { + "Wabbajack.DTOs/0.5.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.Hashing.xxHash64": "0.4.8", - "Wabbajack.Paths": "0.4.8" + "Wabbajack.Hashing.xxHash64": "0.5.0", + "Wabbajack.Paths": "0.5.0" }, "runtime": { "Wabbajack.DTOs.dll": {} } }, - "Wabbajack.FileExtractor/0.4.8": { + "Wabbajack.FileExtractor/0.5.0": { "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.4.8", - "Wabbajack.Compression.BSA": "0.4.8", - "Wabbajack.Hashing.PHash": "0.4.8", - "Wabbajack.Paths": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.Compression.BSA": "0.5.0", + "Wabbajack.Hashing.PHash": "0.5.0", + "Wabbajack.Paths": "0.5.0" }, "runtime": { "Wabbajack.FileExtractor.dll": {} } }, - "Wabbajack.Hashing.PHash/0.4.8": { + "Wabbajack.Hashing.PHash/0.5.0": { "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.4.8", - "Wabbajack.DTOs": "0.4.8", - "Wabbajack.Paths": "0.4.8", - "Wabbajack.Paths.IO": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Paths": "0.5.0", + "Wabbajack.Paths.IO": "0.5.0" }, "runtime": { "Wabbajack.Hashing.PHash.dll": {} } }, - "Wabbajack.Hashing.xxHash64/0.4.8": { + "Wabbajack.Hashing.xxHash64/0.5.0": { "dependencies": { - "Wabbajack.Paths": "0.4.8", - "Wabbajack.RateLimiter": "0.4.8" + "Wabbajack.Paths": "0.5.0", + "Wabbajack.RateLimiter": "0.5.0" }, "runtime": { "Wabbajack.Hashing.xxHash64.dll": {} } }, - "Wabbajack.Installer/0.4.8": { + "Wabbajack.Installer/0.5.0": { "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.4.8", - "Wabbajack.Downloaders.Dispatcher": "0.4.8", - "Wabbajack.Downloaders.GameFile": "0.4.8", - "Wabbajack.FileExtractor": "0.4.8", - "Wabbajack.Networking.WabbajackClientApi": "0.4.8", - "Wabbajack.Paths": "0.4.8", - "Wabbajack.Paths.IO": "0.4.8", - "Wabbajack.VFS": "0.4.8", + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Downloaders.Dispatcher": "0.5.0", + "Wabbajack.Downloaders.GameFile": "0.5.0", + "Wabbajack.FileExtractor": "0.5.0", + "Wabbajack.Networking.NexusApi": "0.5.0", + "Wabbajack.Networking.WabbajackClientApi": "0.5.0", + "Wabbajack.Paths": "0.5.0", + "Wabbajack.Paths.IO": "0.5.0", + "Wabbajack.VFS": "0.5.0", "ini-parser-netstandard": "2.5.2" }, "runtime": { "Wabbajack.Installer.dll": {} } }, - "Wabbajack.IO.Async/0.4.8": { + "Wabbajack.IO.Async/0.5.0": { "runtime": { "Wabbajack.IO.Async.dll": {} } }, - "Wabbajack.Networking.BethesdaNet/0.4.8": { + "Wabbajack.Networking.BethesdaNet/0.5.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.4.8", - "Wabbajack.Networking.Http": "0.4.8", - "Wabbajack.Networking.Http.Interfaces": "0.4.8" + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Networking.Http": "0.5.0", + "Wabbajack.Networking.Http.Interfaces": "0.5.0" }, "runtime": { "Wabbajack.Networking.BethesdaNet.dll": {} } }, - "Wabbajack.Networking.Discord/0.4.8": { + "Wabbajack.Networking.Discord/0.5.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Networking.Http.Interfaces": "0.4.8" + "Wabbajack.Networking.Http.Interfaces": "0.5.0" }, "runtime": { "Wabbajack.Networking.Discord.dll": {} } }, - "Wabbajack.Networking.GitHub/0.4.8": { + "Wabbajack.Networking.GitHub/0.5.0": { "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.4.8", - "Wabbajack.Networking.Http.Interfaces": "0.4.8" + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Networking.Http.Interfaces": "0.5.0" }, "runtime": { "Wabbajack.Networking.GitHub.dll": {} } }, - "Wabbajack.Networking.Http/0.4.8": { + "Wabbajack.Networking.Http/0.5.0": { "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.4.8", - "Wabbajack.Downloaders.Interfaces": "0.4.8", - "Wabbajack.Hashing.xxHash64": "0.4.8", - "Wabbajack.Networking.Http.Interfaces": "0.4.8", - "Wabbajack.Paths": "0.4.8", - "Wabbajack.Paths.IO": "0.4.8" + "Wabbajack.Configuration": "0.5.0", + "Wabbajack.Downloaders.Interfaces": "0.5.0", + "Wabbajack.Hashing.xxHash64": "0.5.0", + "Wabbajack.Networking.Http.Interfaces": "0.5.0", + "Wabbajack.Paths": "0.5.0", + "Wabbajack.Paths.IO": "0.5.0" }, "runtime": { "Wabbajack.Networking.Http.dll": {} } }, - "Wabbajack.Networking.Http.Interfaces/0.4.8": { + "Wabbajack.Networking.Http.Interfaces/0.5.0": { "dependencies": { - "Wabbajack.Hashing.xxHash64": "0.4.8" + "Wabbajack.Hashing.xxHash64": "0.5.0" }, "runtime": { "Wabbajack.Networking.Http.Interfaces.dll": {} } }, - "Wabbajack.Networking.NexusApi/0.4.8": { + "Wabbajack.Networking.NexusApi/0.5.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.4.8", - "Wabbajack.Networking.Http": "0.4.8", - "Wabbajack.Networking.Http.Interfaces": "0.4.8", - "Wabbajack.Networking.WabbajackClientApi": "0.4.8" + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Networking.Http": "0.5.0", + "Wabbajack.Networking.Http.Interfaces": "0.5.0", + "Wabbajack.Networking.WabbajackClientApi": "0.5.0" }, "runtime": { "Wabbajack.Networking.NexusApi.dll": {} } }, - "Wabbajack.Networking.WabbajackClientApi/0.4.8": { + "Wabbajack.Networking.WabbajackClientApi/0.5.0": { "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.4.8", - "Wabbajack.DTOs": "0.4.8", - "Wabbajack.Paths.IO": "0.4.8", - "Wabbajack.VFS.Interfaces": "0.4.8", + "Wabbajack.Common": "0.5.0", + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Paths.IO": "0.5.0", + "Wabbajack.VFS.Interfaces": "0.5.0", "YamlDotNet": "16.3.0" }, "runtime": { "Wabbajack.Networking.WabbajackClientApi.dll": {} } }, - "Wabbajack.Paths/0.4.8": { + "Wabbajack.Paths/0.5.0": { "runtime": { "Wabbajack.Paths.dll": {} } }, - "Wabbajack.Paths.IO/0.4.8": { + "Wabbajack.Paths.IO/0.5.0": { "dependencies": { - "Wabbajack.Paths": "0.4.8", + "Wabbajack.Paths": "0.5.0", "shortid": "4.0.0" }, "runtime": { "Wabbajack.Paths.IO.dll": {} } }, - "Wabbajack.RateLimiter/0.4.8": { + "Wabbajack.RateLimiter/0.5.0": { "runtime": { "Wabbajack.RateLimiter.dll": {} } }, - "Wabbajack.Server.Lib/0.4.8": { + "Wabbajack.Server.Lib/0.5.0": { "dependencies": { "FluentFTP": "52.0.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", @@ -2264,58 +2265,58 @@ "Nettle": "3.0.0", "Newtonsoft.Json": "13.0.3", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Common": "0.4.8", - "Wabbajack.Networking.Http.Interfaces": "0.4.8", - "Wabbajack.Services.OSIntegrated": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.Networking.Http.Interfaces": "0.5.0", + "Wabbajack.Services.OSIntegrated": "0.5.0" }, "runtime": { "Wabbajack.Server.Lib.dll": {} } }, - "Wabbajack.Services.OSIntegrated/0.4.8": { + "Wabbajack.Services.OSIntegrated/0.5.0": { "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.4.8", - "Wabbajack.Downloaders.Dispatcher": "0.4.8", - "Wabbajack.Installer": "0.4.8", - "Wabbajack.Networking.BethesdaNet": "0.4.8", - "Wabbajack.Networking.Discord": "0.4.8", - "Wabbajack.VFS": "0.4.8" + "Wabbajack.Compiler": "0.5.0", + "Wabbajack.Downloaders.Dispatcher": "0.5.0", + "Wabbajack.Installer": "0.5.0", + "Wabbajack.Networking.BethesdaNet": "0.5.0", + "Wabbajack.Networking.Discord": "0.5.0", + "Wabbajack.VFS": "0.5.0" }, "runtime": { "Wabbajack.Services.OSIntegrated.dll": {} } }, - "Wabbajack.VFS/0.4.8": { + "Wabbajack.VFS/0.5.0": { "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.4.8", - "Wabbajack.FileExtractor": "0.4.8", - "Wabbajack.Hashing.PHash": "0.4.8", - "Wabbajack.Hashing.xxHash64": "0.4.8", - "Wabbajack.Paths": "0.4.8", - "Wabbajack.Paths.IO": "0.4.8", - "Wabbajack.VFS.Interfaces": "0.4.8" + "Wabbajack.Common": "0.5.0", + "Wabbajack.FileExtractor": "0.5.0", + "Wabbajack.Hashing.PHash": "0.5.0", + "Wabbajack.Hashing.xxHash64": "0.5.0", + "Wabbajack.Paths": "0.5.0", + "Wabbajack.Paths.IO": "0.5.0", + "Wabbajack.VFS.Interfaces": "0.5.0" }, "runtime": { "Wabbajack.VFS.dll": {} } }, - "Wabbajack.VFS.Interfaces/0.4.8": { + "Wabbajack.VFS.Interfaces/0.5.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.4.8", - "Wabbajack.Hashing.xxHash64": "0.4.8", - "Wabbajack.Paths": "0.4.8" + "Wabbajack.DTOs": "0.5.0", + "Wabbajack.Hashing.xxHash64": "0.5.0", + "Wabbajack.Paths": "0.5.0" }, "runtime": { "Wabbajack.VFS.Interfaces.dll": {} @@ -2332,7 +2333,7 @@ } }, "libraries": { - "jackify-engine/0.4.8": { + "jackify-engine/0.5.0": { "type": "project", "serviceable": false, "sha512": "" @@ -3021,202 +3022,202 @@ "path": "yamldotnet/16.3.0", "hashPath": "yamldotnet.16.3.0.nupkg.sha512" }, - "Wabbajack.CLI.Builder/0.4.8": { + "Wabbajack.CLI.Builder/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Common/0.4.8": { + "Wabbajack.Common/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Compiler/0.4.8": { + "Wabbajack.Compiler/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Compression.BSA/0.4.8": { + "Wabbajack.Compression.BSA/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Compression.Zip/0.4.8": { + "Wabbajack.Compression.Zip/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Configuration/0.4.8": { + "Wabbajack.Configuration/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Bethesda/0.4.8": { + "Wabbajack.Downloaders.Bethesda/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Dispatcher/0.4.8": { + "Wabbajack.Downloaders.Dispatcher/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.GameFile/0.4.8": { + "Wabbajack.Downloaders.GameFile/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.GoogleDrive/0.4.8": { + "Wabbajack.Downloaders.GoogleDrive/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Http/0.4.8": { + "Wabbajack.Downloaders.Http/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Interfaces/0.4.8": { + "Wabbajack.Downloaders.Interfaces/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.8": { + "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Manual/0.4.8": { + "Wabbajack.Downloaders.Manual/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.MediaFire/0.4.8": { + "Wabbajack.Downloaders.MediaFire/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Mega/0.4.8": { + "Wabbajack.Downloaders.Mega/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.ModDB/0.4.8": { + "Wabbajack.Downloaders.ModDB/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Nexus/0.4.8": { + "Wabbajack.Downloaders.Nexus/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.VerificationCache/0.4.8": { + "Wabbajack.Downloaders.VerificationCache/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.WabbajackCDN/0.4.8": { + "Wabbajack.Downloaders.WabbajackCDN/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.DTOs/0.4.8": { + "Wabbajack.DTOs/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.FileExtractor/0.4.8": { + "Wabbajack.FileExtractor/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Hashing.PHash/0.4.8": { + "Wabbajack.Hashing.PHash/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Hashing.xxHash64/0.4.8": { + "Wabbajack.Hashing.xxHash64/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Installer/0.4.8": { + "Wabbajack.Installer/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.IO.Async/0.4.8": { + "Wabbajack.IO.Async/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.BethesdaNet/0.4.8": { + "Wabbajack.Networking.BethesdaNet/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.Discord/0.4.8": { + "Wabbajack.Networking.Discord/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.GitHub/0.4.8": { + "Wabbajack.Networking.GitHub/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.Http/0.4.8": { + "Wabbajack.Networking.Http/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.Http.Interfaces/0.4.8": { + "Wabbajack.Networking.Http.Interfaces/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.NexusApi/0.4.8": { + "Wabbajack.Networking.NexusApi/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.WabbajackClientApi/0.4.8": { + "Wabbajack.Networking.WabbajackClientApi/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Paths/0.4.8": { + "Wabbajack.Paths/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Paths.IO/0.4.8": { + "Wabbajack.Paths.IO/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.RateLimiter/0.4.8": { + "Wabbajack.RateLimiter/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Server.Lib/0.4.8": { + "Wabbajack.Server.Lib/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Services.OSIntegrated/0.4.8": { + "Wabbajack.Services.OSIntegrated/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.VFS/0.4.8": { + "Wabbajack.VFS/0.5.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.VFS.Interfaces/0.4.8": { + "Wabbajack.VFS.Interfaces/0.5.0": { "type": "project", "serviceable": false, "sha512": "" diff --git a/jackify/engine/jackify-engine.dll b/jackify/engine/jackify-engine.dll index 79bcbc8..52d4f8a 100644 Binary files a/jackify/engine/jackify-engine.dll and b/jackify/engine/jackify-engine.dll differ diff --git a/jackify/frontends/cli/commands/manual_download_flow.py b/jackify/frontends/cli/commands/manual_download_flow.py new file mode 100644 index 0000000..63409cb --- /dev/null +++ b/jackify/frontends/cli/commands/manual_download_flow.py @@ -0,0 +1,479 @@ +""" +CLI manual download flow. + +Handles the interactive terminal experience when the engine emits a batch of +files requiring manual download. Uses the same backend services as the GUI path +(ManualDownloadManager, DownloadWatcherService, FileValidatorService) but +outputs status to the terminal and reads simple keyboard commands. +""" + +import os +import sys +import queue +import shutil +import threading +import logging +import time +from pathlib import Path +from typing import Callable, Optional + +from jackify.backend.services.manual_download_manager import ManualDownloadManager, DownloadItem +from jackify.backend.services.download_watcher_service import WatcherConfig +from jackify.backend.handlers.config_handler import ConfigHandler + +logger = logging.getLogger(__name__) + +def _fmt_size(n: int) -> str: + if n <= 0: + return '' + for unit in ('B', 'KB', 'MB', 'GB'): + if n < 1024: + return f"{n:.0f} {unit}" + n /= 1024 + return f"{n:.1f} TB" + + +class CliManualDownloadFlow: + """ + Blocking CLI flow for manual downloads. Returns when all items are done + (complete or skipped) and the continue command has been written to the + engine's stdin pipe. + """ + + def __init__( + self, + items: list[dict], + loop_iteration: int, + download_dir: Path, + stdin_write: Callable[[str], bool], + output_callback: Optional[Callable[[str], None]] = None, + concurrent_limit: int = 2, + ): + self._stdin_write = stdin_write + self._output = output_callback or print + self._done_event = threading.Event() + self._config_handler = ConfigHandler() + self._command_queue: queue.Queue[str] = queue.Queue() + self._last_rendered_snapshot: Optional[str] = None + self._last_render_time = 0.0 + self._completed_successfully = False + self._interactive_tty = bool(getattr(sys.__stdout__, "isatty", lambda: False)()) + self._terminal = sys.__stdout__ if self._interactive_tty else None + self._screen_lines = 0 + self._status_dirty = True + self._notices: list[str] = [] + self._startup_render_blocked = False + + configured_watch = self._config_handler.get("manual_download_watch_directory", None) + watch_dir = None + if configured_watch: + cfg_path = Path(str(configured_watch)).expanduser() + if cfg_path.is_dir(): + watch_dir = cfg_path + if watch_dir is None: + xdg_dl = os.environ.get('XDG_DOWNLOAD_DIR', '') + watch_dir = Path(xdg_dl) if (xdg_dl and Path(xdg_dl).is_dir()) else Path.home() / 'Downloads' + + self._manager = ManualDownloadManager( + modlist_download_dir=download_dir, + watch_directory=watch_dir, + concurrent_limit=concurrent_limit, + on_item_updated=self._on_item_updated, + on_send_continue=self._on_all_done, + on_all_done=self._on_all_done_counts, + ) + self._manager.load_items(items, loop_iteration) + self._total = len(self._manager.items) + self._watch_dir = watch_dir + + def run(self) -> bool: + """Block until all items are complete/skipped and continue is sent.""" + if not self._confirm_start(): + self._output("Manual download phase cancelled.") + return False + self._startup_render_blocked = True + try: + self._manager.start() + finally: + self._startup_render_blocked = False + self._render_status(force=True) + if sys.stdin.isatty(): + threading.Thread(target=self._read_commands, daemon=True).start() + else: + self._output("[interactive commands unavailable: non-interactive stdin]") + + while not self._done_event.is_set(): + self._handle_pending_commands() + self._render_status() + self._done_event.wait(timeout=0.25) + + self._manager.stop() + return self._completed_successfully + + def _on_item_updated(self, item: DownloadItem) -> None: + status = { + 'browser_opened': 'browser opened', + 'validating': 'validating...', + 'complete': '[OK]', + 'deferred': '[deferred]', + 'skipped': '[skipped]', + 'error': '[error]', + }.get(item.status, item.status) + if not self._interactive_tty or item.status in ('error',): + self._output(f" {status:>14} {item.file_name}") + if item.error_message: + self._emit_notice(f"reason: {item.error_message}") + self._status_dirty = True + if self._startup_render_blocked or self._interactive_tty: + return + self._render_status(force=True) + + def _on_all_done_counts(self, completed: int, skipped: int) -> None: + self._completed_successfully = completed == self._total and skipped == 0 + self._emit_notice(f"All downloads done: {completed} complete, {skipped} skipped") + self._emit_notice("Signalling engine to continue...") + + def _on_all_done(self) -> None: + self._stdin_write('{"command":"continue"}') + self._done_event.set() + + def _retry_deferred(self) -> None: + retried = 0 + with self._manager._lock: + for item in self._manager._items: + if item.status in ('deferred', 'skipped'): + item.status = 'pending' + item.needs_user_retry = False + item.error_message = None + retried += 1 + if retried == 0: + self._emit_notice("[no deferred items to retry]") + return + self._emit_notice(f"[retried {retried} deferred item(s)]") + self._manager._open_next_tabs() + + def _set_watch_folder(self, raw: str) -> None: + candidate = Path(raw).expanduser() + if not candidate.is_dir(): + self._emit_notice(f"[invalid directory: {candidate}]") + return + self._watch_dir = candidate + self._manager._watch_dir = candidate + self._manager._watcher._config.watch_directory = candidate + # Force a fresh scan baseline for the newly-selected directory. + self._manager._watcher._known = {} + try: + self._config_handler.set("manual_download_watch_directory", str(candidate)) + self._config_handler.save_config() + except Exception: + logger.debug("Could not persist manual_download_watch_directory", exc_info=True) + self._emit_notice(f"[watch folder set to {candidate}]") + self._status_dirty = True + self._render_status(force=True) + + def _set_concurrency(self, raw: str) -> None: + try: + value = int(raw) + except ValueError: + self._emit_notice(f"[invalid number: {raw}]") + return + value = max(1, min(5, value)) + self._manager.set_concurrent_limit(value) + try: + self._config_handler.set("manual_download_concurrent_limit", value) + self._config_handler.save_config() + except Exception: + logger.debug("Could not persist manual_download_concurrent_limit", exc_info=True) + self._emit_notice(f"[concurrency set to {value}]") + self._status_dirty = True + self._render_status(force=True) + + def _read_commands(self) -> None: + while not self._done_event.is_set(): + try: + line = sys.stdin.readline() + except Exception: + return + if not line: + return + self._command_queue.put(line.strip()) + + def _handle_pending_commands(self) -> None: + while True: + try: + command = self._command_queue.get_nowait() + except queue.Empty: + return + if not command: + continue + if self._handle_command(command): + return + + def _handle_command(self, command: str) -> bool: + parts = command.split(maxsplit=1) + action = parts[0].lower() + arg = parts[1] if len(parts) > 1 else "" + + if action == 'help': + self._print_help() + elif action in ('list', 'ls', 'status'): + self._render_status(force=True) + elif action == 'open': + self._open_item(arg) + elif action in ('defer', 'skip'): + self._defer_item(arg) + elif action == 'retry': + self._retry_deferred() + elif action == 'watch': + if not arg: + self._emit_notice(f"[watch folder: {self._watch_dir}]") + else: + self._set_watch_folder(arg) + elif action == 'pause': + self._manager.pause() + self._emit_notice("[paused]") + elif action == 'resume': + self._manager.resume() + self._emit_notice("[resumed]") + elif action in ('concurrency', 'tabs'): + if not arg: + self._emit_notice(f"[concurrency: {self._manager._limit}]") + else: + self._set_concurrency(arg) + elif action in ('quit', 'exit'): + self._emit_notice("Stopping - downloaded files are preserved for resume.") + self._manager.stop() + self._done_event.set() + return True + else: + self._emit_notice(f"[unknown command: {command}]") + self._print_help() + return False + + def _print_help(self) -> None: + self._output("") + self._output("Commands:") + self._output(" list Show current status") + self._output(" open Re-open a file in the browser") + self._output(" defer Defer an active file") + self._output(" retry Retry all deferred files") + self._output(" watch Change watched download folder") + self._output(" pause | resume Pause or resume auto-open") + self._output(" concurrency <1-5> Set concurrent browser tabs") + self._output(" quit Stop and preserve progress") + self._output("") + + def _render_status(self, force: bool = False) -> None: + now = time.monotonic() + if not force and not self._status_dirty and (now - self._last_render_time) < 2.0: + return + with self._manager._lock: + items = list(self._manager._items) + complete = sum(1 for item in items if item.status == 'complete') + deferred = sum(1 for item in items if item.status in ('deferred', 'skipped')) + active = sum(1 for item in items if item.status == 'browser_opened') + validating = sum(1 for item in items if item.status == 'validating') + pending = sum(1 for item in items if item.status == 'pending') + paused = self._manager._paused + remaining = self._total - complete - deferred + snapshot = ( + f"Watch: {self._watch_dir} | Complete: {complete}/{self._total} | " + f"Active: {active} | Validating: {validating} | Pending: {pending} | " + f"Deferred: {deferred} | Remaining: {remaining} | " + f"Tabs: {self._manager._limit} | {'Paused' if paused else 'Running'}" + ) + if not force and snapshot == self._last_rendered_snapshot: + return + self._last_rendered_snapshot = snapshot + self._last_render_time = now + self._status_dirty = False + recheck_note = None + if self._manager._items: + first = self._manager._items[0] + if first.loop_iteration > 1: + recheck_note = f"Recheck {first.loop_iteration} - still missing: {self._total}" + lines = [ + "", + *self._boxed_lines( + "Jackify CLI Download Manager", + [ + f"Files required: {self._total}", + f"Concurrent browser tabs: {self._manager._limit}", + f"Watching: {self._watch_dir}", + *( [recheck_note] if recheck_note else [] ), + ], + ), + *self._boxed_lines( + "Action Required", + [ + "Check your browser now.", + "Jackify may have opened Nexus download pages in the background.", + "Type `help` at any time for available commands.", + ], + ), + *self._boxed_lines("Status", [snapshot]), + *self._boxed_lines( + "Downloads", + [self._format_table_header(), *[self._format_item(item) for item in self._visible_items(items)]], + ), + *self._boxed_lines( + "Commands", + ["help | list | open | defer | retry | watch | pause | resume | concurrency <1-5> | quit"], + ), + *self._boxed_lines("Notices", self._notices[-3:] or ["None"]), + "", + ] + if self._interactive_tty and self._terminal is not None: + self._redraw_terminal(lines) + else: + for line in lines: + self._output(line) + + def _visible_items(self, items: list[DownloadItem]) -> list[DownloadItem]: + priority = {'browser_opened': 0, 'validating': 1, 'pending': 2, 'deferred': 3, 'skipped': 4, 'error': 5, 'complete': 6} + return sorted(items, key=lambda item: (priority.get(item.status, 9), item.index))[:14] + + def _format_item(self, item: DownloadItem) -> str: + status = { + 'browser_opened': 'OPEN ', + 'validating': 'CHECK', + 'pending': 'WAIT ', + 'deferred': 'DEFER', + 'skipped': 'SKIP ', + 'error': 'ERROR', + 'complete': 'DONE ', + }.get(item.status, item.status[:5].upper()) + size = _fmt_size(item.expected_size).rjust(7) if item.expected_size else ' ' * 7 + mod_name = self._truncate(item.mod_name or "", 24).ljust(24) + file_name = self._truncate(item.file_name, 32).ljust(32) + return f"{item.index:>3}/{item.total:<3} {status} {size} {mod_name} {file_name}" + + def _find_item(self, raw: str) -> Optional[DownloadItem]: + if not raw: + self._emit_notice("[missing item index]") + return None + if raw.isdigit(): + target_index = int(raw) + for item in self._manager.items: + if item.index == target_index: + return item + low = raw.lower() + for item in self._manager.items: + if item.file_name.lower() == low: + return item + self._emit_notice(f"[item not found: {raw}]") + return None + + def _open_item(self, raw: str) -> None: + item = self._find_item(raw) + if not item: + return + if self._manager.reopen_item(item.file_name): + self._emit_notice(f"[opened] {item.file_name}") + else: + self._emit_notice(f"[could not open] {item.file_name}") + + def _defer_item(self, raw: str) -> None: + item = self._find_item(raw) + if not item: + return + if item.status not in ('browser_opened', 'pending', 'error'): + self._emit_notice(f"[cannot defer item in state: {item.status}]") + return + self._manager.skip_item(item.file_name) + self._emit_notice(f"[deferred] {item.file_name}") + self._status_dirty = True + + def _confirm_start(self) -> bool: + self._output("") + for line in self._boxed_lines( + "Non-Premium Manual Download Flow", + [ + "Jackify detected that manual Nexus downloads are required.", + "It will open Nexus pages in your browser, watch your download folder,", + "validate files automatically, and resume once everything is present.", + "", + "Key commands:", + "concurrency 1-5 Change simultaneous browser tabs", + "defer Skip an item for now", + "retry Reopen deferred items", + "watch Change monitored download folder", + ], + ): + self._output(line) + if not sys.stdin.isatty(): + return True + self._output("Continue? [Y/n]: ") + try: + response = (sys.stdin.readline() or "").strip().lower() + except Exception: + return False + return response in ("", "y", "yes") + + def _redraw_terminal(self, lines: list[str]) -> None: + if self._terminal is None: + return + terminal_width = self._terminal_columns() + if self._screen_lines: + self._terminal.write(f"\x1b[{self._screen_lines}F") + self._terminal.write("\x1b[J") + for line in lines: + self._terminal.write(line[: max(1, terminal_width - 1)] + "\n") + self._terminal.flush() + self._screen_lines = len(lines) + + @staticmethod + def _truncate(value: str, width: int) -> str: + if len(value) <= width: + return value + if width <= 3: + return value[:width] + return value[: width - 3] + "..." + + @staticmethod + def _format_table_header() -> str: + return "Idx State Size Mod File" + + def _boxed_lines(self, title: str, rows: list[str], width: Optional[int] = None) -> list[str]: + width = width or max(60, min(100, self._terminal_columns() - 2)) + inner_width = max(20, width - 4) + top = "+" + "-" * (width - 2) + "+" + rendered = [top, f"| {self._truncate(title, inner_width).ljust(inner_width)} |"] + if rows: + rendered.append("|" + "-" * (width - 2) + "|") + for row in rows: + rendered.append(f"| {self._truncate(row, inner_width).ljust(inner_width)} |") + rendered.append(top) + return rendered + + def _terminal_columns(self) -> int: + return shutil.get_terminal_size(fallback=(100, 24)).columns + + def _emit_notice(self, message: str) -> None: + if self._interactive_tty: + self._notices.append(message) + self._status_dirty = True + return + self._output(message) + +def run_cli_manual_download_phase( + events: list[dict], + loop_iteration: int, + download_dir: Path, + stdin_write: Callable[[str], bool], + output_callback: Optional[Callable[[str], None]] = None, + concurrent_limit: int = 2, +) -> bool: + """ + Entry point called from modlist_service_installation when the engine emits + a manual_download_list_complete event. Blocks until done. + """ + flow = CliManualDownloadFlow( + items=events, + loop_iteration=loop_iteration, + download_dir=download_dir, + stdin_write=stdin_write, + output_callback=output_callback, + concurrent_limit=concurrent_limit, + ) + return flow.run() diff --git a/jackify/frontends/cli/commands/vnv_manual_downloads.py b/jackify/frontends/cli/commands/vnv_manual_downloads.py new file mode 100644 index 0000000..f0ae0bc --- /dev/null +++ b/jackify/frontends/cli/commands/vnv_manual_downloads.py @@ -0,0 +1,140 @@ +"""CLI helpers for VNV manual-download handling.""" + +from pathlib import Path +from typing import Callable, Optional + +from jackify.backend.services.nexus_premium_service import NexusPremiumService +from jackify.backend.services.vnv_post_install_service import VNVPostInstallService +from jackify.frontends.cli.commands.manual_download_flow import run_cli_manual_download_phase +from jackify.frontends.cli.ui.indeterminate_status import CliIndeterminateStatus + + +def _is_explicitly_non_premium(service: VNVPostInstallService) -> bool: + auth_token = service.auth_service.get_auth_token() + auth_method = service.auth_service.get_auth_method() + if not auth_token or not auth_method: + return False + is_premium, username = NexusPremiumService().check_premium_status( + auth_token, + is_oauth=auth_method == "oauth", + ) + return username is not None and not is_premium + + +def _missing_manual_items(service: VNVPostInstallService) -> list[dict]: + completed = service.check_already_completed() + include_bsa = not completed["bsa_decompressed"] and not ( + service._find_cached_bsa_mpi() or service._find_cached_bsa_package() + ) + include_4gb = not completed["4gb_patch"] and not service._find_cached_4gb_patcher() + if not include_4gb and not include_bsa: + return [] + items = service.get_manual_download_items(include_bsa=include_bsa) + if include_4gb: + return items + return [item for item in items if int(item.get("mod_id", 0)) != service.LINUX_4GB_PATCHER_MOD_ID] + + +def ensure_vnv_cli_manual_downloads( + service: VNVPostInstallService, + output_callback: Optional[Callable[[str], None]] = None, +) -> bool: + if not _is_explicitly_non_premium(service): + return True + items = _missing_manual_items(service) + if not items: + return True + output = output_callback or print + output("") + output("VNV requires manual Nexus downloads for this account. Opening Jackify CLI Download Manager...") + return run_cli_manual_download_phase( + events=items, + loop_iteration=1, + download_dir=service.cache_dir, + stdin_write=lambda _payload: True, + output_callback=output, + concurrent_limit=2, + ) + + +def build_vnv_cli_manual_file_callback( + service: VNVPostInstallService, + output_callback: Optional[Callable[[str], None]] = None, +): + output = output_callback or print + manual_items = service.get_manual_download_items(include_bsa=True) + + def _cached_file_for_title(title: str) -> Optional[Path]: + if "4GB" in title: + return service._find_cached_4gb_patcher() + return service._find_cached_bsa_mpi() or service._find_cached_bsa_package() + + def _manual_file_callback(title: str, instructions: str) -> Optional[Path]: + cached = _cached_file_for_title(title) + if cached: + return cached + mod_id = ( + service.LINUX_4GB_PATCHER_MOD_ID + if "4GB" in title + else service.FNV_BSA_DECOMPRESSOR_MOD_ID + ) + item = next((entry for entry in manual_items if int(entry.get("mod_id", 0)) == mod_id), None) + if not item: + output("") + output(instructions) + return None + output("") + output(f"{title} - opening Jackify CLI Download Manager...") + success = run_cli_manual_download_phase( + events=[item], + loop_iteration=1, + download_dir=service.cache_dir, + stdin_write=lambda _payload: True, + output_callback=output, + concurrent_limit=1, + ) + if not success: + return None + return _cached_file_for_title(title) + + return _manual_file_callback + + +def create_vnv_cli_progress_callback( + output_callback: Optional[Callable[[str], None]] = None, +) -> tuple[Callable[[str], None], Callable[[], None]]: + """Create a CLI progress callback with a pulser for indeterminate VNV stages.""" + output = output_callback or print + pulser = CliIndeterminateStatus() + + def _should_pulse(message: str) -> bool: + lowered = message.lower() + if "%" in lowered: + return False + if "assets processed:" in lowered: + return False + if "decompressing bsa files:" in lowered: + return False + pulse_markers = ( + "running vnv post-install automation", + "running bsa decompressor", + "running 4gb patcher", + "preparing bsa decompressor package", + "extracting bsa package", + "ensuring ttw_linux_installer is available", + "checking for post-install automation", + "finalizing post-install configuration", + ) + return any(marker in lowered for marker in pulse_markers) + + def _progress(message: str) -> None: + text = (message or "").strip() + if not text: + return + if _should_pulse(text): + pulser.set(text) + return + pulser.stop() + output(text) + + return _progress, pulser.close diff --git a/jackify/frontends/cli/menus/additional_menu.py b/jackify/frontends/cli/menus/additional_menu.py index 299a86b..4d25b48 100644 --- a/jackify/frontends/cli/menus/additional_menu.py +++ b/jackify/frontends/cli/menus/additional_menu.py @@ -150,16 +150,103 @@ class AdditionalMenuHandler: else: output_path = Path(output_path).expanduser() + # 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()) + except Exception: + has_files = False + if has_files: + print(f"\n{COLOR_WARNING}The TTW output directory already exists and contains files:{COLOR_RESET}") + print(f" {output_path}") + print(f"{COLOR_WARNING}All files in this directory will be deleted before installation.{COLOR_RESET}") + print(f"{COLOR_WARNING}This action cannot be undone.{COLOR_RESET}") + confirm = input(f"{COLOR_PROMPT}Delete existing files and continue? (y/N): {COLOR_RESET}").strip().lower() + if confirm not in ('y', 'yes'): + print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}") + input("Press Enter to return to menu...") + return + import shutil + try: + for item in output_path.iterdir(): + if item.is_dir(): + shutil.rmtree(item) + else: + item.unlink() + except Exception as e: + print(f"{COLOR_ERROR}Failed to clear output directory: {e}{COLOR_RESET}") + input("Press Enter to return to menu...") + return + # Run TTW installation + import re + + phase_state = {"current": "Processing", "last_rendered": ""} + progress_line_active = {"value": False} + + def _strip_ansi(text: str) -> str: + return re.sub(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])', '', text or '') + + def _ttw_output_callback(line: str): + clean = _strip_ansi(line or "").strip() + if not clean: + return + lower = clean.lower() + rendered = "" + manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower) + if manifest_match: + current = int(manifest_match.group(1)) + total = int(manifest_match.group(2)) + phase_state["current"] = "Loading manifest" + percent = int((current / total) * 100) if total > 0 else 0 + rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)" + else: + progress_match = re.search(r'\[(\d+)/(\d+)\]', clean) + if progress_match: + current = int(progress_match.group(1)) + total = int(progress_match.group(2)) + percent = int((current / total) * 100) if total > 0 else 0 + rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)" + else: + if 'manifest' in lower: + phase_state["current"] = "Loading manifest" + elif any(t in lower for t in ('extract', 'decompress', 'installing', 'copying', 'merge')): + phase_state["current"] = clean + is_milestone = any(t in lower for t in ('===', 'complete', 'finished', 'starting', 'valid')) + is_error = 'error:' in lower + is_warning = 'warning:' in lower + if is_milestone or is_error or is_warning: + rendered = f"[TTW] {clean}" + + if not rendered or rendered == phase_state["last_rendered"]: + return + phase_state["last_rendered"] = rendered + if re.search(r'^\[TTW\] .+?: [\d,]+/[\d,]+ \(\d+%\)$', rendered): + print(f"\r{COLOR_INFO}{rendered}{COLOR_RESET}", end="", flush=True) + progress_line_active["value"] = True + else: + if progress_line_active["value"]: + print() + progress_line_active["value"] = False + print(f"{COLOR_INFO}{rendered}{COLOR_RESET}") + print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}") - success, message = ttw_installer_handler.install_ttw_backend(mpi_path, output_path) - + print(f"{COLOR_INFO}This may take 15-30 minutes.{COLOR_RESET}\n") + success, message = ttw_installer_handler.install_ttw_backend_with_output_stream( + mpi_path, output_path, output_callback=_ttw_output_callback + ) + if progress_line_active["value"]: + print() + if success: print(f"\n{COLOR_SUCCESS}TTW installation completed successfully!{COLOR_RESET}") print(f"{COLOR_INFO}TTW installed to: {output_path}{COLOR_RESET}") + print(f"{COLOR_INFO}Detailed log available at: ~/Jackify/logs/TTW_Install_workflow.log{COLOR_RESET}") + input("Press Enter to return to menu...") else: print(f"\n{COLOR_ERROR}TTW installation failed.{COLOR_RESET}") print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}") + print(f"{COLOR_INFO}Detailed log available at: ~/Jackify/logs/TTW_Install_workflow.log{COLOR_RESET}") input("Press Enter to return to menu...") def _execute_nexus_authorization(self, cli_instance): diff --git a/jackify/frontends/cli/ui/indeterminate_status.py b/jackify/frontends/cli/ui/indeterminate_status.py new file mode 100644 index 0000000..6ca12b5 --- /dev/null +++ b/jackify/frontends/cli/ui/indeterminate_status.py @@ -0,0 +1,70 @@ +"""Single-line CLI pulser for indeterminate background stages.""" + +from __future__ import annotations + +import itertools +import sys +import threading +import time +from typing import Optional + + +class CliIndeterminateStatus: + """Render one in-place pulsing status line for long-running CLI steps.""" + + def __init__(self, output=None, interval: float = 0.12): + self._output = output or sys.stdout + self._interval = interval + self._interactive = bool(getattr(self._output, "isatty", lambda: False)()) + self._message: Optional[str] = None + self._printed_message: Optional[str] = None + self._stop_event = threading.Event() + self._lock = threading.Lock() + self._thread: Optional[threading.Thread] = None + + def set(self, message: str) -> None: + """Start or update the active pulsing message.""" + cleaned = (message or "").strip() + if not cleaned: + self.stop() + return + if not self._interactive: + if cleaned != self._printed_message: + print(cleaned, file=self._output, flush=True) + self._printed_message = cleaned + return + with self._lock: + self._message = cleaned + if self._thread and self._thread.is_alive(): + return + self._stop_event.clear() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the pulser and clear its terminal line.""" + if not self._interactive: + return + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=0.5) + self._thread = None + with self._lock: + self._message = None + self._output.write("\r\033[2K") + self._output.flush() + + def close(self) -> None: + self.stop() + + def _run(self) -> None: + for frame in itertools.cycle("|/-\\"): + if self._stop_event.wait(self._interval): + return + with self._lock: + message = self._message + if not message: + continue + self._output.write(f"\r\033[2K{message} {frame}") + self._output.flush() + diff --git a/jackify/frontends/gui/dialogs/existing_setup_dialog.py b/jackify/frontends/gui/dialogs/existing_setup_dialog.py new file mode 100644 index 0000000..e58327a --- /dev/null +++ b/jackify/frontends/gui/dialogs/existing_setup_dialog.py @@ -0,0 +1,204 @@ +"""Shared dialog for existing install/shortcut detection decisions.""" + +from __future__ import annotations + +from typing import Optional, Tuple + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + + +def prompt_existing_setup_dialog( + parent: QWidget, + *, + window_title: str, + heading: str, + body: str, + existing_name: str, + requested_name: str, + install_dir: Optional[str] = None, + field_label: str = "New shortcut name", + reuse_label: str = "Use Existing Setup", + new_label: str = "Create New Shortcut", + cancel_label: str = "Cancel", +) -> Tuple[str, Optional[str]]: + """ + Show the shared existing-setup dialog. + + Returns: + ("reuse"|"new"|"cancel", new_name_or_none) + """ + dialog = QDialog(parent) + dialog.setWindowTitle(window_title) + dialog.setModal(True) + dialog.setMinimumWidth(760) + dialog.setMinimumHeight(320) + + dialog.setStyleSheet( + """ + QDialog { + background: #181818; + color: #ffffff; + border-radius: 12px; + } + QFrame#dialogCard { + background: #23272e; + border: 1px solid #353a40; + border-radius: 12px; + } + QFrame#infoCard { + background: #2a2f36; + border: 1px solid #3b4148; + border-radius: 8px; + } + QLabel { + color: #ffffff; + font-size: 14px; + padding: 0px; + } + QLabel#dialogTitle { + font-size: 22px; + font-weight: 600; + color: #3fb7d6; + } + QLabel#dialogBody { + color: #e0e0e0; + line-height: 1.35; + } + QLabel#infoLabel { + color: #c7d0d8; + font-size: 13px; + line-height: 1.3; + } + QLabel#fieldLabel { + color: #b0b0b0; + font-size: 12px; + } + QLineEdit { + background-color: #404040; + color: #ffffff; + border: 2px solid #555555; + border-radius: 4px; + padding: 8px; + font-size: 14px; + selection-background-color: #3fd0ea; + } + QLineEdit:focus { + border-color: #3fd0ea; + } + QPushButton { + background-color: #404040; + color: #ffffff; + border: 2px solid #555555; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + min-width: 120px; + } + QPushButton:hover { + background-color: #505050; + border-color: #3fd0ea; + } + QPushButton:pressed { + background-color: #303030; + } + """ + ) + + outer_layout = QVBoxLayout(dialog) + outer_layout.setContentsMargins(24, 20, 24, 20) + outer_layout.setSpacing(0) + + card = QFrame(dialog) + card.setObjectName("dialogCard") + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(22, 22, 22, 22) + card_layout.setSpacing(14) + + title_label = QLabel(heading) + title_label.setObjectName("dialogTitle") + title_label.setAlignment(Qt.AlignCenter) + title_label.setWordWrap(True) + card_layout.addWidget(title_label) + + body_label = QLabel(body) + body_label.setObjectName("dialogBody") + body_label.setAlignment(Qt.AlignCenter) + body_label.setWordWrap(True) + card_layout.addWidget(body_label) + + info_card = QFrame(card) + info_card.setObjectName("infoCard") + info_layout = QVBoxLayout(info_card) + info_layout.setContentsMargins(14, 12, 14, 12) + info_layout.setSpacing(6) + + info_lines = [ + f"Existing shortcut: {existing_name}", + f"Requested name: {requested_name or existing_name}", + ] + if install_dir: + info_lines.append(f"Install directory: {install_dir}") + info_label = QLabel("
".join(info_lines)) + info_label.setObjectName("infoLabel") + info_label.setTextFormat(Qt.RichText) + info_label.setWordWrap(True) + info_layout.addWidget(info_label) + card_layout.addWidget(info_card) + + field_title = QLabel(field_label) + field_title.setObjectName("fieldLabel") + card_layout.addWidget(field_title) + + name_input = QLineEdit(requested_name or existing_name) + name_input.selectAll() + card_layout.addWidget(name_input) + + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + + reuse_button = QPushButton(reuse_label) + cancel_button = QPushButton(cancel_label) + new_button = QPushButton(new_label) + for button in (reuse_button, cancel_button, new_button): + button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + button_layout.addWidget(reuse_button) + button_layout.addWidget(cancel_button) + button_layout.addWidget(new_button) + card_layout.addLayout(button_layout) + outer_layout.addWidget(card) + + result = {"action": "cancel", "new_name": None} + + def on_reuse(): + result["action"] = "reuse" + dialog.accept() + + def on_new(): + result["action"] = "new" + result["new_name"] = name_input.text().strip() + dialog.accept() + + def on_cancel(): + result["action"] = "cancel" + dialog.reject() + + reuse_button.clicked.connect(on_reuse) + new_button.clicked.connect(on_new) + cancel_button.clicked.connect(on_cancel) + name_input.returnPressed.connect(on_new) + + dialog.adjustSize() + dialog.exec() + return result["action"], result["new_name"] diff --git a/jackify/frontends/gui/dialogs/manual_download_dialog.py b/jackify/frontends/gui/dialogs/manual_download_dialog.py new file mode 100644 index 0000000..382ac8f --- /dev/null +++ b/jackify/frontends/gui/dialogs/manual_download_dialog.py @@ -0,0 +1,466 @@ +""" +Manual Download Dialog + +Shown when the engine requires manual downloads (non-premium or forced-manual +archives). Displays all pending items in a scrollable table, manages browser +tab concurrency, and coordinates with ManualDownloadManager. +""" + +import logging +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import Qt, Signal, QObject +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QSpinBox, QFrame, QTableWidget, QTableWidgetItem, QHeaderView, + QProgressBar, QFileDialog, QSizePolicy, +) +from PySide6.QtGui import QFont + +from jackify.backend.services.manual_download_manager import ManualDownloadManager, DownloadItem +from jackify.backend.handlers.config_handler import ConfigHandler +from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE + +logger = logging.getLogger(__name__) + +_STATUS_LABELS = { + 'pending': 'Pending', + 'browser_opened': 'Browser Opened', + 'validating': 'Validating...', + 'complete': 'Complete', + 'deferred': 'Deferred', + 'skipped': 'Skipped', + 'error': 'Error', +} + +_STATUS_COLOURS = { + 'pending': '#808080', + 'browser_opened': '#3498db', + 'validating': '#f39c12', + 'complete': '#27ae60', + 'deferred': '#e67e22', + 'skipped': '#e67e22', + 'error': '#e74c3c', +} + +# Column indices +_COL_MOD = 0 +_COL_NAME = 1 +_COL_SIZE = 2 +_COL_STATUS = 3 + + +def _fmt_size(n: int) -> str: + if n <= 0: + return '' + for unit in ('B', 'KB', 'MB', 'GB'): + if n < 1024: + return f"{n:.0f} {unit}" + n /= 1024 + return f"{n:.1f} TB" + + +class _Bridge(QObject): + """Tiny bridge so worker-thread callbacks can update the Qt table safely.""" + item_updated = Signal(object) # DownloadItem + all_done = Signal(int, int) # completed, skipped + + +class ManualDownloadDialog(QDialog): + """ + Displays all pending manual downloads and coordinates the download workflow. + Non-modal so the install log remains visible. + """ + + def __init__( + self, + manager: ManualDownloadManager, + modlist_name: str = '', + watch_directory: Optional[Path] = None, + concurrent_limit: int = 2, + parent=None, + ): + super().__init__(parent) + self._manager = manager + self._modlist_name = modlist_name + self._watch_dir = watch_directory or (Path.home() / 'Downloads') + self._paused = False + self._started = False + self._initial_concurrent_limit = max(1, min(5, int(concurrent_limit))) + + # Row index by file_name for fast updates + self._row_map: dict[str, int] = {} + + # Bridge for thread-safe table updates + self._bridge = _Bridge() + self._bridge.item_updated.connect(self._on_item_updated_slot) + self._bridge.all_done.connect(self._on_all_done_slot) + + # Preserve any existing manager callbacks so workflow controllers still + # receive completion events after the dialog updates its own UI. + prev_item_updated = self._manager._on_item_updated + prev_all_done = self._manager._on_all_done + + def _emit_item_updated(item): + self._bridge.item_updated.emit(item) + if prev_item_updated: + prev_item_updated(item) + + def _emit_all_done(completed: int, skipped: int): + self._bridge.all_done.emit(completed, skipped) + if prev_all_done: + prev_all_done(completed, skipped) + + self._manager._on_item_updated = _emit_item_updated + self._manager._on_all_done = _emit_all_done + + self.setWindowTitle("Manual Downloads Required") + self.setMinimumSize(760, 500) + self.setModal(False) + self._build_ui() + + # ------------------------------------------------------------------ + # Public + # ------------------------------------------------------------------ + + def showEvent(self, event) -> None: + super().showEvent(event) + if not self._started: + # Keep the workflow idle until the user explicitly clicks Start. + # Start backend services in paused mode so watcher/precheck are ready + # without opening browser tabs yet. + self._paused = False + self._manager.pause() + self._manager.start() + self._start_pause_btn.setText("Start") + self._progress_label.setText("Ready - click Start to begin opening download tabs") + + def load_items(self, items: list[DownloadItem]) -> None: + """ + Populate or refresh the table from a list of DownloadItems. + On subsequent loop iterations the manager passes its full item list + (including previously-completed rows), so we update existing rows and + append only genuinely new ones rather than rebuilding the table. + """ + new_items = [i for i in items if i.file_name not in self._row_map] + existing_items = [i for i in items if i.file_name in self._row_map] + + # Update existing rows without disabling updates (usually few on repeat iterations) + for item in existing_items: + self._update_row(self._row_map[item.file_name], item) + + # Batch-insert new rows with viewport updates suspended to avoid O(n²) repaints + if new_items: + self._table.setUpdatesEnabled(False) + try: + start_row = self._table.rowCount() + self._table.setRowCount(start_row + len(new_items)) + for i, item in enumerate(new_items): + self._fill_row(start_row + i, item) + self._row_map[item.file_name] = start_row + i + finally: + self._table.setUpdatesEnabled(True) + self._table.viewport().update() + + self._refresh_header() + # If user already started the workflow and engine enters another manual loop, + # continue opening tabs for newly-pending items automatically. + if self._started and not self._paused: + self._manager.resume() + + def update_item(self, item: DownloadItem) -> None: + """Called from any thread - bridges to Qt slot.""" + self._bridge.item_updated.emit(item) + + # ------------------------------------------------------------------ + # Build UI + # ------------------------------------------------------------------ + + def _build_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(16, 16, 16, 16) + root.setSpacing(10) + + # Header + hdr = QFrame() + hdr.setFrameShape(QFrame.StyledPanel) + hdr.setStyleSheet("QFrame { background: #1e2228; border-radius: 8px; border: 1px solid #333; }") + hdr_layout = QVBoxLayout(hdr) + hdr_layout.setContentsMargins(12, 10, 12, 10) + hdr_layout.setSpacing(6) + + self._title_label = QLabel(f"Modlist: {self._modlist_name or 'Unknown'}") + self._title_label.setStyleSheet("color: #e0e0e0; font-size: 14px; font-weight: 600;") + hdr_layout.addWidget(self._title_label) + + self._progress_label = QLabel("Preparing...") + self._progress_label.setStyleSheet("color: #aaaaaa; font-size: 12px;") + hdr_layout.addWidget(self._progress_label) + + self._progress_bar = QProgressBar() + self._progress_bar.setRange(0, 100) + self._progress_bar.setValue(0) + self._progress_bar.setStyleSheet( + f"QProgressBar {{ border: 1px solid #444; border-radius: 4px; background: #2c2c2c; " + f"height: 12px; color: #d7e3f4; font-weight: 600; }}" + f"QProgressBar::chunk {{ background: {JACKIFY_COLOR_BLUE}; border-radius: 3px; }}" + ) + hdr_layout.addWidget(self._progress_bar) + root.addWidget(hdr) + + # Table + self._table = QTableWidget() + self._table.setColumnCount(4) + self._table.setHorizontalHeaderLabels(['Mod', 'File', 'Size', 'Status']) + self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Fixed) + self._table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Fixed) + self._table.setColumnWidth(2, 90) + self._table.setColumnWidth(3, 130) + self._table.setSelectionBehavior(QTableWidget.SelectRows) + self._table.setEditTriggers(QTableWidget.NoEditTriggers) + self._table.setAlternatingRowColors(True) + self._table.verticalHeader().setVisible(False) + self._table.cellDoubleClicked.connect(self._on_row_double_clicked) + self._table.setStyleSheet( + "QTableWidget { background: #1a1d23; alternate-background-color: #1e2228; " + "color: #d0d0d0; gridline-color: #333; border: 1px solid #333; border-radius: 4px; }" + "QHeaderView::section { background: #252830; color: #aaa; border: none; " + "padding: 4px; font-size: 11px; }" + ) + root.addWidget(self._table) + + # Controls row + ctrl = QHBoxLayout() + ctrl.setSpacing(12) + + ctrl.addWidget(QLabel("Concurrent tabs:")) + self._concurrent_spin = QSpinBox() + self._concurrent_spin.setRange(1, 5) + self._concurrent_spin.setValue(self._initial_concurrent_limit) + self._concurrent_spin.setFixedWidth(60) + self._concurrent_spin.valueChanged.connect(self._on_concurrent_changed) + ctrl.addWidget(self._concurrent_spin) + + ctrl.addSpacing(16) + ctrl.addWidget(QLabel("Watch folder:")) + self._folder_label = QLabel(str(self._watch_dir)) + self._folder_label.setStyleSheet("color: #aaa; font-size: 11px;") + self._folder_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + ctrl.addWidget(self._folder_label) + + folder_btn = QPushButton("...") + folder_btn.setFixedSize(32, 28) + folder_btn.clicked.connect(self._on_pick_folder) + ctrl.addWidget(folder_btn) + + root.addLayout(ctrl) + + watch_hint = QLabel( + "Jackify watches this folder for newly downloaded archives, validates them, " + "then moves valid files into your modlist downloads folder automatically. " + "Double-click a row (or use Open Selected) to reopen a URL if you closed a tab." + ) + watch_hint.setWordWrap(True) + watch_hint.setStyleSheet("color: #8f98a3; font-size: 11px;") + root.addWidget(watch_hint) + + # Action buttons + btn_row = QHBoxLayout() + btn_row.setSpacing(10) + + self._retry_btn = QPushButton("Retry Deferred (0)") + self._retry_btn.setEnabled(False) + self._retry_btn.clicked.connect(self._on_retry_skipped) + btn_row.addWidget(self._retry_btn) + + self._defer_btn = QPushButton("Defer Selected") + self._defer_btn.clicked.connect(self._on_defer_selected) + btn_row.addWidget(self._defer_btn) + + self._open_selected_btn = QPushButton("Open Selected") + self._open_selected_btn.clicked.connect(self._on_open_selected) + btn_row.addWidget(self._open_selected_btn) + + btn_row.addStretch() + + self._start_pause_btn = QPushButton("Start") + self._start_pause_btn.clicked.connect(self._on_start_pause_clicked) + btn_row.addWidget(self._start_pause_btn) + + cancel_btn = QPushButton("Cancel") + cancel_btn.setStyleSheet( + "QPushButton { background: #7f2020; color: white; border: none; " + "border-radius: 4px; padding: 6px 16px; }" + "QPushButton:hover { background: #9b2828; }" + ) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(cancel_btn) + + root.addLayout(btn_row) + + # ------------------------------------------------------------------ + # Table helpers + # ------------------------------------------------------------------ + + def _fill_row(self, row: int, item: DownloadItem) -> None: + """Populate cells for a pre-allocated row (row must already exist in the table).""" + from PySide6.QtGui import QColor + self._table.setItem(row, _COL_MOD, QTableWidgetItem(item.mod_name)) + self._table.setItem(row, _COL_NAME, QTableWidgetItem(item.file_name)) + self._table.setItem(row, _COL_SIZE, QTableWidgetItem(_fmt_size(item.expected_size))) + colour = _STATUS_COLOURS.get(item.status, '#808080') + status_cell = QTableWidgetItem(_STATUS_LABELS.get(item.status, item.status)) + status_cell.setForeground(QColor(colour)) + if item.error_message: + status_cell.setToolTip(item.error_message) + self._table.setItem(row, _COL_STATUS, status_cell) + + def _update_row(self, row: int, item: DownloadItem) -> None: + from PySide6.QtGui import QColor + status_cell = self._table.item(row, _COL_STATUS) + if status_cell: + status_cell.setText(_STATUS_LABELS.get(item.status, item.status)) + status_cell.setForeground(QColor(_STATUS_COLOURS.get(item.status, '#808080'))) + status_cell.setToolTip(item.error_message or "") + + def _refresh_header(self) -> None: + items = self._manager.items + total = len(items) + complete = sum(1 for i in items if i.status == 'complete') + skipped = sum(1 for i in items if i.status == 'skipped') + remaining = total - complete - skipped + + pct = int(complete / total * 100) if total > 0 else 0 + self._progress_bar.setValue(pct) + self._progress_label.setText( + f"{complete} of {total} complete | {skipped} deferred | {remaining} remaining" + ) + self._retry_btn.setText(f"Retry Deferred ({skipped})") + self._retry_btn.setEnabled(skipped > 0) + + # ------------------------------------------------------------------ + # Slots + # ------------------------------------------------------------------ + + def _on_item_updated_slot(self, item: DownloadItem) -> None: + row = self._row_map.get(item.file_name) + if row is not None: + self._update_row(row, item) + self._refresh_header() + + def _on_concurrent_changed(self, value: int) -> None: + self._manager.set_concurrent_limit(value) + try: + cfg = ConfigHandler() + cfg.set("manual_download_concurrent_limit", int(value)) + cfg.save_config() + except Exception: + 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)) + if chosen: + from jackify.backend.services.download_watcher_service import WatcherConfig + self._watch_dir = Path(chosen) + self._folder_label.setText(chosen) + self._manager._watch_dir = self._watch_dir + self._manager._watcher._config.watch_directory = self._watch_dir + self._manager._watcher._known = {} + try: + cfg = ConfigHandler() + cfg.set("manual_download_watch_directory", str(self._watch_dir)) + cfg.save_config() + except Exception: + logger.debug("Could not persist manual_download_watch_directory", exc_info=True) + + def _on_start_pause_clicked(self) -> None: + if not self._started: + self._started = True + self._paused = False + self._start_pause_btn.setText("Pause") + self._manager.resume() + return + + if not self._paused: + self._paused = True + self._start_pause_btn.setText("Resume") + self._manager.pause() + else: + self._paused = False + self._start_pause_btn.setText("Pause") + self._manager.resume() + + def _on_retry_skipped(self) -> None: + with self._manager._lock: + for item in self._manager._items: + if item.status in ('deferred', 'skipped'): + item.status = 'pending' + item.needs_user_retry = False + row = self._row_map.get(item.file_name) + if row is not None: + self._update_row(row, item) + self._manager._open_next_tabs() + self._refresh_header() + + def _on_defer_selected(self) -> None: + row = self._table.currentRow() + if row < 0: + return + file_item = self._table.item(row, _COL_NAME) + if file_item is None: + return + file_name = file_item.text().strip() + if not file_name: + return + self._manager.skip_item(file_name) + + def _on_open_selected(self) -> None: + row = self._table.currentRow() + if row < 0: + return + file_item = self._table.item(row, _COL_NAME) + if file_item is None: + return + file_name = file_item.text().strip() + if not file_name: + return + self._manager.reopen_item(file_name) + + def _on_row_double_clicked(self, row: int, _column: int) -> None: + file_item = self._table.item(row, _COL_NAME) + if file_item is None: + return + file_name = file_item.text() + if not file_name: + return + self._manager.reopen_item(file_name) + + 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..." + ) + # Raise now while the dialog is still visible so the user sees the completion state + self._raise_main_window() + QTimer.singleShot(2000, self._close_and_refocus) + + def _close_and_refocus(self) -> None: + self.close() + # Closing a non-modal dialog can hand focus back to whatever was behind it + self._raise_main_window() + + def _raise_main_window(self) -> None: + try: + win = self.window() + if win: + win.raise_() + win.activateWindow() + except Exception: + pass + + def closeEvent(self, event) -> None: + # Don't stop the manager on close - install continues + event.accept() diff --git a/jackify/frontends/gui/dialogs/settings_dialog.py b/jackify/frontends/gui/dialogs/settings_dialog.py index 90d3f22..b070d00 100644 --- a/jackify/frontends/gui/dialogs/settings_dialog.py +++ b/jackify/frontends/gui/dialogs/settings_dialog.py @@ -274,13 +274,20 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog self.config_handler.set("proton_path", resolved_install_path) self.config_handler.set("proton_version", resolved_install_version) else: - # No Proton found - don't write anything, let engine auto-detect + # No Proton found - clear persisted selection so startup normalization + # can auto-heal once a compatible Proton is installed. logger.warning("Auto Proton selection failed: No Proton versions found") - # Don't modify existing config values + resolved_install_path = None + resolved_install_version = None + self.config_handler.set("proton_path", None) + self.config_handler.set("proton_version", None) except Exception as e: - # Exception during detection - log it and don't write anything + # Exception during detection - clear persisted selection to avoid stale path usage. logger.error(f"Auto Proton selection failed with exception: {e}", exc_info=True) - # Don't modify existing config values + resolved_install_path = None + resolved_install_version = None + self.config_handler.set("proton_path", None) + self.config_handler.set("proton_version", None) else: # User selected specific Proton version resolved_install_path = selected_install_proton_path @@ -392,4 +399,3 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog label = QLabel(text) label.setStyleSheet("font-weight: bold; color: #fff;") return label - diff --git a/jackify/frontends/gui/dialogs/success_dialog.py b/jackify/frontends/gui/dialogs/success_dialog.py index f36d34e..c50e1f7 100644 --- a/jackify/frontends/gui/dialogs/success_dialog.py +++ b/jackify/frontends/gui/dialogs/success_dialog.py @@ -86,6 +86,8 @@ class SuccessDialog(QDialog): modlist_name_html = f'{self.modlist_name}' if self.workflow_type == "install": suffix_text = "installed successfully!" + elif self.workflow_type == "update": + suffix_text = "updated successfully!" elif self.workflow_type == "configure_new": suffix_text = "configured successfully!" elif self.workflow_type == "configure_existing": @@ -220,6 +222,7 @@ class SuccessDialog(QDialog): """ workflow_messages = { "install": f"{self.modlist_name} installed successfully!", + "update": f"{self.modlist_name} updated successfully!", "configure_new": f"{self.modlist_name} configured successfully!", "configure_existing": f"{self.modlist_name} configuration updated successfully!", "tuxborn": f"Tuxborn installation completed successfully!", @@ -268,4 +271,4 @@ class SuccessDialog(QDialog): QApplication.quit() except Exception as e: logger.error(f"Error during safe exit: {e}") - QApplication.quit() \ No newline at end of file + QApplication.quit() diff --git a/jackify/frontends/gui/mixins/main_window_geometry.py b/jackify/frontends/gui/mixins/main_window_geometry.py index ad8d60a..5b49d90 100644 --- a/jackify/frontends/gui/mixins/main_window_geometry.py +++ b/jackify/frontends/gui/mixins/main_window_geometry.py @@ -44,6 +44,12 @@ class MainWindowGeometryMixin: def _is_compact_mode(self) -> bool: try: + if hasattr(self, 'wabbajack_installer_screen') and hasattr(self.wabbajack_installer_screen, 'show_details_checkbox'): + if self.wabbajack_installer_screen.show_details_checkbox.isChecked(): + return False + if hasattr(self, 'install_mo2_screen') and hasattr(self.install_mo2_screen, 'show_details_checkbox'): + if self.install_mo2_screen.show_details_checkbox.isChecked(): + return False if hasattr(self, 'install_modlist_screen') and hasattr(self.install_modlist_screen, 'show_details_checkbox'): if self.install_modlist_screen.show_details_checkbox.isChecked(): return False diff --git a/jackify/frontends/gui/mixins/main_window_ui.py b/jackify/frontends/gui/mixins/main_window_ui.py index 6f24791..1458414 100644 --- a/jackify/frontends/gui/mixins/main_window_ui.py +++ b/jackify/frontends/gui/mixins/main_window_ui.py @@ -220,6 +220,10 @@ class MainWindowUIMixin: stacked_widget=self.stacked_widget, additional_tasks_index=3, system_info=self.system_info ) self.install_mo2_screen = screen + try: + screen.resize_request.connect(self._on_child_resize_request) + except Exception: + pass return screen def _debug_screen_change(self, index): diff --git a/jackify/frontends/gui/screens/configure_existing_modlist.py b/jackify/frontends/gui/screens/configure_existing_modlist.py index ed03b4f..ed64f06 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist.py @@ -32,6 +32,7 @@ from .configure_existing_modlist_shortcuts import ConfigureExistingModlistShortc from .configure_existing_modlist_console import ConfigureExistingModlistConsoleMixin from .screen_back_mixin import ScreenBackMixin from .install_modlist_ttw import TTWIntegrationMixin +from .install_modlist_postinstall import PostInstallFeedbackMixin class ConfigureExistingModlistScreen( ScreenBackMixin, @@ -40,23 +41,35 @@ class ConfigureExistingModlistScreen( ConfigureExistingModlistWorkflowMixin, ConfigureExistingModlistShortcutsMixin, ConfigureExistingModlistConsoleMixin, + PostInstallFeedbackMixin, QWidget, ): resize_request = Signal(str) def cleanup_processes(self): """Clean up any running processes when the window closes or is cancelled""" - # Stop CPU tracking if active if hasattr(self, 'file_progress_list'): self.file_progress_list.stop_cpu_tracking() - # Clean up configuration thread if running - if hasattr(self, 'config_thread') and self.config_thread.isRunning(): - self.config_thread.terminate() - self.config_thread.wait(1000) + from PySide6.QtCore import QThread + for attr_name, value in list(vars(self).items()): + try: + if isinstance(value, QThread) and value.isRunning(): + try: + value.finished_signal.disconnect() + except Exception: + pass + value.terminate() + value.wait(2000) + setattr(self, attr_name, None) + except Exception: + pass def cancel_and_cleanup(self): """Handle Cancel button - clean up processes and go back""" + if getattr(self, '_vnv_controller', None) is not None: + self._vnv_controller.cleanup() + self._vnv_controller = None self.cleanup_processes() self.collapse_show_details_before_leave() self.go_back() @@ -65,16 +78,8 @@ class ConfigureExistingModlistScreen( """Called when the widget becomes visible - ensure collapsed state""" super().showEvent(event) - # Ensure initial collapsed layout first so UI is stable before async load try: - from PySide6.QtCore import Qt as _Qt - if self.show_details_checkbox.isChecked(): - self.show_details_checkbox.blockSignals(True) - self.show_details_checkbox.setChecked(False) - self.show_details_checkbox.blockSignals(False) - self._toggle_console_visibility(False) - - # Only set minimum size - DO NOT RESIZE + self.force_collapsed_details_state() main_window = self.window() if main_window: from PySide6.QtCore import QSize @@ -118,8 +123,15 @@ class ConfigureExistingModlistScreen( return # Check for VNV post-install automation after configuration - if install_dir: - self._check_and_run_vnv_automation(modlist_name, install_dir) + if install_dir and self._check_and_run_vnv_automation(modlist_name, install_dir): + self._pending_success_dialog_params = { + 'modlist_name': modlist_name, + 'workflow_type': 'configure_existing', + 'time_taken': self._calculate_time_taken(), + 'game_name': getattr(self, '_current_game_name', None), + 'enb_detected': enb_detected, + } + return # Calculate time taken time_taken = self._calculate_time_taken() @@ -202,10 +214,15 @@ class ConfigureExistingModlistScreen( # Re-enable controls (in case they were disabled from previous errors) self._enable_controls_after_operation() + self.force_collapsed_details_state() def cleanup(self): """Clean up any running threads when the screen is closed""" logger.debug("DEBUG: cleanup called - cleaning up ConfigurationThread") + + if getattr(self, '_vnv_controller', None) is not None: + self._vnv_controller.cleanup() + self._vnv_controller = None # Clean up config thread if running if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning(): diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_ui.py b/jackify/frontends/gui/screens/configure_existing_modlist_ui.py index b50567c..ac1c22f 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist_ui.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist_ui.py @@ -51,6 +51,12 @@ class ConfigureExistingModlistUIMixin: self.progress_indicator = OverallProgressIndicator(show_progress_bar=True) self.progress_indicator.set_status("Ready to configure", 0) self.file_progress_list = FileProgressList() + self._post_install_sequence = self._build_post_install_sequence() + self._post_install_total_steps = len(self._post_install_sequence) + self._post_install_current_step = 0 + self._post_install_active = False + self._post_install_last_label = "" + self._bsa_hold_deadline = 0.0 # Create "Show Details" checkbox self.show_details_checkbox = QCheckBox("Show details") @@ -539,4 +545,3 @@ class ConfigureExistingModlistUIMixin: self.process_monitor.setPlainText('\n'.join(filtered)) except Exception as e: self.process_monitor.setPlainText(f"[process info unavailable: {e}]") - diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py b/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py index ec45cf8..8bda5c7 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py @@ -1,10 +1,11 @@ """Workflow management for ConfigureExistingModlistScreen (Mixin).""" from PySide6.QtCore import QThread, Signal +from PySide6.QtWidgets import QMessageBox import os import time import logging from pathlib import Path -from typing import Optional + from jackify.shared.resolution_utils import get_resolution_fallback from jackify.shared.errors import configuration_failed @@ -188,7 +189,10 @@ class ConfigureExistingModlistWorkflowMixin: ) if not success: - self.error_occurred.emit("Configuration failed - check logs for details") + self.error_occurred.emit( + "Configuration did not complete successfully. " + "Review the latest workflow output above for the failing step." + ) except Exception as e: import traceback @@ -206,89 +210,64 @@ class ConfigureExistingModlistWorkflowMixin: self._safe_append_text(f"[ERROR] Failed to start configuration: {e}") MessageService.show_error(self, configuration_failed(str(e))) - def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str): - """Check if VNV automation should run and execute if applicable + def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool: + """Check if VNV automation should run and start it if applicable. - Args: - modlist_name: Name of the installed modlist - install_dir: Installation directory path + Returns: + True if VNV automation is starting (caller should defer success dialog) + False if no VNV needed (show success dialog immediately) """ - try: - from pathlib import Path - from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation - from jackify.backend.services.automated_prefix_service import AutomatedPrefixService - from jackify.backend.handlers.path_handler import PathHandler + from ..services.vnv_automation_controller import VNVAutomationController - # Get paths first (needed for VNV detection) - install_path = Path(install_dir) - - # Quick check before importing more (pass install location for ModOrganizer.ini check) - if not should_offer_vnv_automation(modlist_name, install_path): - return - game_paths = PathHandler().find_vanilla_game_paths() - game_root = game_paths.get('Fallout New Vegas') + self._vnv_controller = VNVAutomationController() + return self._vnv_controller.attempt( + parent=self, + modlist_name=modlist_name, + install_dir=install_dir, + on_progress=self._safe_append_text, + on_complete=self._on_vnv_complete, + begin_feedback=self._begin_post_install_feedback, + handle_feedback=self._handle_post_install_progress, + ) - if not game_root: - logger.debug("DEBUG: VNV automation skipped - FNV game root not found") - return - - # Confirmation callback - show dialog to user - def confirmation_callback(description: str) -> bool: - from ..services.message_service import MessageService - reply = MessageService.question( - self, - "VNV Post-Install Automation", - description, - critical=False, - safety_level="medium" - ) - return reply == QMessageBox.Yes - - # Manual file callback for non-Premium users - def manual_file_callback(title: str, instructions: str) -> Optional[Path]: - from PySide6.QtWidgets import QFileDialog - from ..services.message_service import MessageService - - # Show instructions - MessageService.information(self, title, instructions) - - # Open file picker - file_path, _ = QFileDialog.getOpenFileName( - self, - title, - str(Path.home() / "Downloads"), - "All Files (*.*)" - ) - - if file_path: - return Path(file_path).resolve() - return None - - # Run automation - automation_ran, error = run_vnv_automation_if_applicable( - modlist_name=modlist_name, - modlist_install_location=install_path, - game_root=game_root, - ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), - progress_callback=None, # GUI doesn't need progress updates for post-install - manual_file_callback=manual_file_callback, - confirmation_callback=confirmation_callback + def _on_vnv_complete(self, success: bool, error: str): + """Handle VNV automation completion and show deferred success dialog.""" + self._end_post_install_feedback(not bool(error)) + if not success and error: + from ..services.message_service import MessageService + MessageService.warning( + self, + "VNV Automation Failed", + f"VNV post-install automation encountered an error:\n\n{error}\n\n" + "You can complete these steps manually by following the guide at:\n" + "https://vivanewvegas.moddinglinked.com/wabbajack.html" ) + elif success: + self._safe_append_text("VNV post-install automation completed successfully.") - if error: - from ..services.message_service import MessageService - MessageService.warning( - self, - "VNV Automation Failed", - f"VNV post-install automation encountered an error:\n\n{error}\n\n" - "You can complete these steps manually by following the guide at:\n" - "https://vivanewvegas.moddinglinked.com/wabbajack.html" - ) + if hasattr(self, '_pending_success_dialog_params'): + params = self._pending_success_dialog_params + del self._pending_success_dialog_params - except Exception as e: - logger.debug(f"ERROR: Failed to run VNV automation: {e}") - import traceback - logger.debug(f"Traceback: {traceback.format_exc()}") + self.file_progress_list.clear() + + from ..dialogs import SuccessDialog + success_dialog = SuccessDialog( + modlist_name=params['modlist_name'], + workflow_type=params['workflow_type'], + time_taken=params['time_taken'], + game_name=params['game_name'], + parent=self, + ) + success_dialog.show() + + if params.get('enb_detected'): + try: + from ..dialogs.enb_proton_dialog import ENBProtonDialog + enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self) + enb_dialog.exec() + except Exception as e: + logger.warning("Failed to show ENB dialog: %s", e) def show_manual_steps_dialog(self, extra_warning=""): modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist" @@ -372,4 +351,3 @@ class ConfigureExistingModlistWorkflowMixin: return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds" else: return f"{elapsed_seconds_remainder} seconds" - diff --git a/jackify/frontends/gui/screens/configure_new_modlist.py b/jackify/frontends/gui/screens/configure_new_modlist.py index d358200..735dd7c 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist.py +++ b/jackify/frontends/gui/screens/configure_new_modlist.py @@ -35,14 +35,18 @@ from .configure_new_modlist_workflow import ConfigureNewModlistWorkflowMixin from .configure_new_modlist_dialogs import ConfigureNewModlistDialogsMixin, ModlistFetchThread, SelectionDialog from .screen_back_mixin import ScreenBackMixin from .install_modlist_ttw import TTWIntegrationMixin +from .install_modlist_postinstall import PostInstallFeedbackMixin logger = logging.getLogger(__name__) -class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, QWidget): +class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget): resize_request = Signal(str) def cancel_and_cleanup(self): """Handle Cancel button - clean up processes and go back""" + if getattr(self, '_vnv_controller', None) is not None: + self._vnv_controller.cleanup() + self._vnv_controller = None self.cleanup_processes() self.collapse_show_details_before_leave() self.go_back() @@ -50,23 +54,7 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN def showEvent(self, event): """Called when the widget becomes visible - ensure collapsed state""" super().showEvent(event) - - # Ensure initial collapsed layout each time this screen is opened - try: - from PySide6.QtCore import Qt as _Qt - # Ensure checkbox is unchecked without emitting signals - if self.show_details_checkbox.isChecked(): - self.show_details_checkbox.blockSignals(True) - self.show_details_checkbox.setChecked(False) - self.show_details_checkbox.blockSignals(False) - - # Force collapsed state - # Set console to hidden state without emitting signals - self.console.setVisible(False) - self.resize_request.emit("compact") - except Exception as e: - # If initial collapse fails, log but don't crash - print(f"Warning: Failed to set initial collapsed state: {e}") + self.force_collapsed_details_state() def on_configuration_complete(self, success, message, modlist_name, enb_detected=False): """Handle configuration completion (same as Tuxborn)""" @@ -88,8 +76,15 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN return # Check for VNV post-install automation after configuration - if install_dir: - self._check_and_run_vnv_automation(modlist_name, install_dir) + if install_dir and self._check_and_run_vnv_automation(modlist_name, install_dir): + self._pending_success_dialog_params = { + 'modlist_name': modlist_name, + 'workflow_type': 'configure_new', + 'time_taken': self._calculate_time_taken(), + 'game_name': getattr(self, '_current_game_name', None), + 'enb_detected': enb_detected, + } + return # Calculate time taken time_taken = self._calculate_time_taken() @@ -97,7 +92,6 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN # Clear Activity window before showing success dialog self.file_progress_list.clear() - # Show success dialog with celebration success_dialog = SuccessDialog( modlist_name=modlist_name, workflow_type="configure_new", @@ -106,16 +100,14 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN parent=self ) success_dialog.show() - - # Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection) + if enb_detected: try: from ..dialogs.enb_proton_dialog import ENBProtonDialog enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self) - enb_dialog.exec() # Modal dialog - blocks until user clicks OK + enb_dialog.exec() except Exception as e: - # Non-blocking: if dialog fails, just log and continue - logger.warning(f"Failed to show ENB dialog: {e}") + logger.warning("Failed to show ENB dialog: %s", e) else: self._safe_append_text(f"Configuration failed: {message}") MessageService.show_error(self, configuration_failed(str(message))) @@ -169,10 +161,15 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN # Re-enable controls (in case they were disabled from previous errors) self._enable_controls_after_operation() + self.force_collapsed_details_state() 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(): diff --git a/jackify/frontends/gui/screens/configure_new_modlist_console.py b/jackify/frontends/gui/screens/configure_new_modlist_console.py index a5d0ac3..a02c6e4 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_console.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_console.py @@ -3,13 +3,14 @@ import os import re import time -from PySide6.QtCore import QTimer +from PySide6.QtCore import QTimer, Qt from PySide6.QtWidgets import QFileDialog from jackify.shared.progress_models import FileProgress, OperationType +from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL -class ConfigureNewModlistConsoleMixin: +class ConfigureNewModlistConsoleMixin(FocusReclaimMixin): """Mixin providing console output management for ConfigureNewModlistScreen.""" def _handle_progress_update(self, text): @@ -26,10 +27,12 @@ class ConfigureNewModlistConsoleMixin: self._stop_component_install_pulse() self.progress_indicator.set_status("Restarting Steam...", 20) self.file_progress_list.update_or_add_item("__phase__", "Restarting Steam...", 0.0) - elif "steam restart" in message_lower and "success" in message_lower: + elif "steam started successfully" in message_lower or ("steam restart" in message_lower and "success" in message_lower): self._stop_component_install_pulse() self.progress_indicator.set_status("Steam restarted successfully", 30) self.file_progress_list.update_or_add_item("__phase__", "Steam restarted", 0.0) + elif STEAM_RESTART_SENTINEL in text: + self._start_focus_reclaim_retries() elif "creating proton prefix" in message_lower or "prefix creation" in message_lower: self._stop_component_install_pulse() self.progress_indicator.set_status("Creating Proton prefix...", 50) @@ -169,4 +172,3 @@ class ConfigureNewModlistConsoleMixin: if file: self.install_dir_edit.setText(os.path.realpath(file)) - diff --git a/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py b/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py index 01a0035..a0908bf 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py @@ -1,10 +1,12 @@ """Dialog management for ConfigureNewModlistScreen (Mixin).""" +import os from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, QFileDialog, QMessageBox, QApplication, QListWidget, QListWidgetItem from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QTextCursor from pathlib import Path -from typing import Optional + import subprocess +from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog from jackify.frontends.gui.services.message_service import MessageService from jackify.shared.errors import manual_steps_incomplete import logging @@ -75,131 +77,85 @@ class SelectionDialog(QDialog): class ConfigureNewModlistDialogsMixin: """Mixin providing dialog management for ConfigureNewModlistScreen.""" + def _restore_controls_after_shortcut_dialog_abort(self): + """Return Configure New to an editable state when shortcut resolution is aborted.""" + try: + self._enable_controls_after_operation() + except Exception: + pass + def cleanup_processes(self): """Clean up any running processes when the window closes or is cancelled""" - # Stop CPU tracking if active if hasattr(self, 'file_progress_list'): self.file_progress_list.stop_cpu_tracking() - # Clean up automated prefix thread if running - if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread.isRunning(): - self.automated_prefix_thread.terminate() - self.automated_prefix_thread.wait(1000) - - # Clean up configuration thread if running - if hasattr(self, 'config_thread') and self.config_thread.isRunning(): - self.config_thread.terminate() - self.config_thread.wait(1000) + 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 def show_shortcut_conflict_dialog(self, conflicts): - """Show dialog to resolve shortcut name conflicts""" + """Show dialog to reuse an existing shortcut or choose a new name.""" conflict_names = [c['name'] for c in conflicts] - conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'" - + existing_name = conflict_names[0] + modlist_name = self.modlist_name_edit.text().strip() - - # Create dialog with Jackify styling - from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout - from PySide6.QtCore import Qt - - dialog = QDialog(self) - dialog.setWindowTitle("Steam Shortcut Conflict") - dialog.setModal(True) - dialog.resize(450, 180) - - # Apply Jackify dark theme styling - dialog.setStyleSheet(""" - QDialog { - background-color: #2b2b2b; - color: #ffffff; - } - QLabel { - color: #ffffff; - font-size: 14px; - padding: 10px 0px; - } - QLineEdit { - background-color: #404040; - color: #ffffff; - border: 2px solid #555555; - border-radius: 4px; - padding: 8px; - font-size: 14px; - selection-background-color: #3fd0ea; - } - QLineEdit:focus { - border-color: #3fd0ea; - } - QPushButton { - background-color: #404040; - color: #ffffff; - border: 2px solid #555555; - border-radius: 4px; - padding: 8px 16px; - font-size: 14px; - min-width: 120px; - } - QPushButton:hover { - background-color: #505050; - border-color: #3fd0ea; - } - QPushButton:pressed { - background-color: #303030; - } - """) - - layout = QVBoxLayout(dialog) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(15) - - # Conflict message - conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:") - layout.addWidget(conflict_label) - - # Text input for new name - name_input = QLineEdit(modlist_name) - name_input.selectAll() - layout.addWidget(name_input) - - # Buttons - button_layout = QHBoxLayout() - button_layout.setSpacing(10) - - create_button = QPushButton("Create with New Name") - cancel_button = QPushButton("Cancel") - - button_layout.addStretch() - button_layout.addWidget(cancel_button) - button_layout.addWidget(create_button) - layout.addLayout(button_layout) - + install_dir = os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip() + + action, new_name = prompt_existing_setup_dialog( + self, + window_title="Existing Modlist Setup Detected", + heading="Modlist Update or New Install", + body=( + "Jackify detected an existing Steam shortcut for this setup.\n\n" + "If you are updating an existing modlist or reconfiguring it, choose " + "'Use Existing Setup'. If you want a separate Steam entry, enter a different " + "name and choose 'Create New Shortcut'." + ), + existing_name=existing_name, + requested_name=modlist_name, + install_dir=install_dir, + field_label="New shortcut name", + reuse_label="Use Existing Setup", + new_label="Create New Shortcut", + cancel_label="Cancel", + ) + # Connect signals - def on_create(): - new_name = name_input.text().strip() + if action == "new": if new_name and new_name != modlist_name: - dialog.accept() - # Retry workflow with new name self.retry_automated_workflow_with_new_name(new_name) elif new_name == modlist_name: - # Same name - show warning - from jackify.backend.services.message_service import MessageService MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") + self._restore_controls_after_shortcut_dialog_abort() else: - # Empty name - from jackify.backend.services.message_service import MessageService MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") - - def on_cancel(): - dialog.reject() + self._restore_controls_after_shortcut_dialog_abort() + elif action == "reuse": + existing_appid = conflicts[0].get('appid') + if not existing_appid: + MessageService.warning( + self, + "Existing Setup Not Found", + "Jackify could not determine the Steam AppID for the existing shortcut.", + ) + self._restore_controls_after_shortcut_dialog_abort() + return + self._safe_append_text(f"Reusing existing Steam shortcut '{existing_name}'.") + self.continue_configuration_after_automated_prefix( + str(existing_appid), + existing_name, + install_dir, + None, + ) + else: self._safe_append_text("Shortcut creation cancelled by user") - - create_button.clicked.connect(on_create) - cancel_button.clicked.connect(on_cancel) - - # Make Enter key work - name_input.returnPressed.connect(on_create) - - dialog.exec() + self._restore_controls_after_shortcut_dialog_abort() def retry_automated_workflow_with_new_name(self, new_name): """Retry the automated workflow with a new shortcut name""" @@ -228,89 +184,64 @@ class ConfigureNewModlistDialogsMixin: MessageService.show_error(self, manual_steps_incomplete()) self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self.modlist_name_edit.text().strip()) - def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str): - """Check if VNV automation should run and execute if applicable + def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool: + """Check if VNV automation should run and start it if applicable. - Args: - modlist_name: Name of the installed modlist - install_dir: Installation directory path + Returns: + True if VNV automation is starting (caller should defer success dialog) + False if no VNV needed (show success dialog immediately) """ - try: - from pathlib import Path - from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation - from jackify.backend.services.automated_prefix_service import AutomatedPrefixService - from jackify.backend.handlers.path_handler import PathHandler + from ..services.vnv_automation_controller import VNVAutomationController - # Get paths first (needed for VNV detection) - install_path = Path(install_dir) - - # Quick check before importing more (pass install location for ModOrganizer.ini check) - if not should_offer_vnv_automation(modlist_name, install_path): - return - game_paths = PathHandler().find_vanilla_game_paths() - game_root = game_paths.get('Fallout New Vegas') + self._vnv_controller = VNVAutomationController() + return self._vnv_controller.attempt( + parent=self, + modlist_name=modlist_name, + install_dir=install_dir, + on_progress=self._safe_append_text, + on_complete=self._on_vnv_complete, + begin_feedback=self._begin_post_install_feedback, + handle_feedback=self._handle_post_install_progress, + ) - if not game_root: - logger.debug("DEBUG: VNV automation skipped - FNV game root not found") - return - - # Confirmation callback - show dialog to user - def confirmation_callback(description: str) -> bool: - from ..services.message_service import MessageService - reply = MessageService.question( - self, - "VNV Post-Install Automation", - description, - critical=False, - safety_level="medium" - ) - return reply == QMessageBox.Yes - - # Manual file callback for non-Premium users - def manual_file_callback(title: str, instructions: str) -> Optional[Path]: - from PySide6.QtWidgets import QFileDialog - from ..services.message_service import MessageService - - # Show instructions - MessageService.information(self, title, instructions) - - # Open file picker - file_path, _ = QFileDialog.getOpenFileName( - self, - title, - str(Path.home() / "Downloads"), - "All Files (*.*)" - ) - - if file_path: - return Path(file_path).resolve() - return None - - # Run automation - automation_ran, error = run_vnv_automation_if_applicable( - modlist_name=modlist_name, - modlist_install_location=install_path, - game_root=game_root, - ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), - progress_callback=None, # GUI doesn't need progress updates for post-install - manual_file_callback=manual_file_callback, - confirmation_callback=confirmation_callback + def _on_vnv_complete(self, success: bool, error: str): + """Handle VNV automation completion and show deferred success dialog.""" + self._end_post_install_feedback(not bool(error)) + if not success and error: + from ..services.message_service import MessageService + MessageService.warning( + self, + "VNV Automation Failed", + f"VNV post-install automation encountered an error:\n\n{error}\n\n" + "You can complete these steps manually by following the guide at:\n" + "https://vivanewvegas.moddinglinked.com/wabbajack.html" ) + elif success: + self._safe_append_text("VNV post-install automation completed successfully.") - if error: - from ..services.message_service import MessageService - MessageService.warning( - self, - "VNV Automation Failed", - f"VNV post-install automation encountered an error:\n\n{error}\n\n" - "You can complete these steps manually by following the guide at:\n" - "https://vivanewvegas.moddinglinked.com/wabbajack.html" - ) + if hasattr(self, '_pending_success_dialog_params'): + params = self._pending_success_dialog_params + del self._pending_success_dialog_params - except Exception as e: - logger.debug(f"ERROR: Failed to run VNV automation: {e}") - import traceback - logger.debug(f"Traceback: {traceback.format_exc()}") + self.file_progress_list.clear() + + from ..dialogs import SuccessDialog + success_dialog = SuccessDialog( + modlist_name=params['modlist_name'], + workflow_type=params['workflow_type'], + time_taken=params['time_taken'], + game_name=params['game_name'], + parent=self, + ) + success_dialog.show() + + if params.get('enb_detected'): + try: + from ..dialogs.enb_proton_dialog import ENBProtonDialog + enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self) + enb_dialog.exec() + except Exception as e: + logger.warning("Failed to show ENB dialog: %s", e) def show_next_steps_dialog(self, message): dlg = QDialog(self) @@ -335,4 +266,3 @@ class ConfigureNewModlistDialogsMixin: btn_return.clicked.connect(on_return) btn_exit.clicked.connect(on_exit) dlg.exec() - diff --git a/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py b/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py index d931ce1..0fba309 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py @@ -55,6 +55,12 @@ class ConfigureNewModlistUISetupMixin: self.progress_indicator = OverallProgressIndicator(show_progress_bar=True) self.progress_indicator.set_status("Ready to configure", 0) self.file_progress_list = FileProgressList() + self._post_install_sequence = self._build_post_install_sequence() + self._post_install_total_steps = len(self._post_install_sequence) + self._post_install_current_step = 0 + self._post_install_active = False + self._post_install_last_label = "" + self._bsa_hold_deadline = 0.0 # Create "Show Details" checkbox self.show_details_checkbox = QCheckBox("Show details") @@ -601,4 +607,3 @@ class ConfigureNewModlistUISetupMixin: f"Unable to verify protontricks installation: {e}\n\n" "Continuing anyway, but some features may not work correctly.") return True # Continue anyway - diff --git a/jackify/frontends/gui/screens/configure_new_modlist_workflow.py b/jackify/frontends/gui/screens/configure_new_modlist_workflow.py index b08d586..c75918d 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_workflow.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_workflow.py @@ -145,7 +145,6 @@ class ConfigureNewModlistWorkflowMixin: progress_update = Signal(str) workflow_complete = Signal(object) # Will emit the result tuple error_occurred = Signal(object) # error (JackifyError or str) - def __init__(self, modlist_name, install_dir, mo2_exe_path, steamdeck, auto_restart): super().__init__() self.modlist_name = modlist_name @@ -153,27 +152,23 @@ class ConfigureNewModlistWorkflowMixin: self.mo2_exe_path = mo2_exe_path self.steamdeck = steamdeck self.auto_restart = auto_restart - + def run(self): try: from jackify.backend.services.automated_prefix_service import AutomatedPrefixService - - # Initialize the automated prefix service + prefix_service = AutomatedPrefixService() - - # Define progress callback for GUI updates + def progress_callback(message): self.progress_update.emit(message) - - # Run the automated workflow (this contains the blocking operations) + result = prefix_service.run_working_workflow( - self.modlist_name, self.install_dir, self.mo2_exe_path, + self.modlist_name, self.install_dir, self.mo2_exe_path, progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart ) - - # Emit the result + self.workflow_complete.emit(result) - + except Exception as e: from jackify.shared.errors import JackifyError, prefix_creation_failed if not isinstance(e, JackifyError): @@ -474,7 +469,10 @@ class ConfigureNewModlistWorkflowMixin: ) if not success: - self.error_occurred.emit("Configuration failed - check logs for details") + self.error_occurred.emit( + "Configuration did not complete successfully. " + "Review the latest workflow output above for the failing step." + ) except Exception as e: import traceback @@ -509,4 +507,3 @@ class ConfigureNewModlistWorkflowMixin: return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds" else: return f"{elapsed_seconds_remainder} seconds" - diff --git a/jackify/frontends/gui/screens/install_mo2_screen.py b/jackify/frontends/gui/screens/install_mo2_screen.py index 014e080..c4da4da 100644 --- a/jackify/frontends/gui/screens/install_mo2_screen.py +++ b/jackify/frontends/gui/screens/install_mo2_screen.py @@ -7,6 +7,7 @@ MO2SetupService. No Wabbajack modlist required. import logging import os +from datetime import datetime from pathlib import Path from typing import Optional @@ -18,11 +19,14 @@ from PySide6.QtWidgets import ( from PySide6.QtCore import Qt, QThread, Signal, QSize from jackify.backend.models.configuration import SystemInfo +from jackify.backend.services.automated_prefix_service import AutomatedPrefixService from jackify.shared.errors import mo2_setup_failed 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 .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL from ..widgets.progress_indicator import OverallProgressIndicator from ..widgets.file_progress_list import FileProgressList from .screen_back_mixin import ScreenBackMixin @@ -37,10 +41,11 @@ class MO2SetupWorker(QThread): log_output = Signal(str) setup_complete = Signal(bool, object, str) # success, app_id (int|None), error_msg - def __init__(self, install_dir: Path, shortcut_name: str): + def __init__(self, install_dir: Path, shortcut_name: str, existing_appid: int | None = None): super().__init__() self.install_dir = install_dir self.shortcut_name = shortcut_name + self.existing_appid = existing_appid def run(self): from jackify.backend.services.mo2_setup_service import MO2SetupService @@ -56,6 +61,7 @@ class MO2SetupWorker(QThread): success, app_id, error_msg = service.setup_mo2( install_dir=self.install_dir, shortcut_name=self.shortcut_name, + existing_appid=self.existing_appid, progress_callback=_progress, should_cancel=self.isInterruptionRequested, ) @@ -68,7 +74,7 @@ class MO2SetupWorker(QThread): self.setup_complete.emit(False, None, str(e)) -class InstallMO2Screen(ScreenBackMixin, QWidget): +class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget): """Standalone MO2 setup screen""" resize_request = Signal(str) @@ -90,6 +96,10 @@ class InstallMO2Screen(ScreenBackMixin, QWidget): self._user_manually_scrolled = False self._was_at_bottom = True + from jackify.shared.paths import get_jackify_logs_dir + self.log_path = get_jackify_logs_dir() / "MO2_Install_workflow.log" + os.makedirs(os.path.dirname(self.log_path), exist_ok=True) + self.progress_indicator = OverallProgressIndicator(show_progress_bar=False) self.progress_indicator.set_status("Ready", 0) @@ -281,7 +291,16 @@ class InstallMO2Screen(ScreenBackMixin, QWidget): def _on_show_details_toggled(self, checked): self.console.setVisible(checked) - self.resize_request.emit("expand" if checked else "collapse") + if checked: + self.console.setMinimumHeight(200) + self.console.setMaximumHeight(16777215) + self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.resize_request.emit("expand") + else: + self.console.setMinimumHeight(0) + self.console.setMaximumHeight(0) + self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + self.resize_request.emit("compact") def _browse_folder(self): folder = QFileDialog.getExistingDirectory( @@ -339,15 +358,75 @@ class InstallMO2Screen(ScreenBackMixin, QWidget): if confirm != QMessageBox.Yes: return + existing_appid = None + candidate_exe = install_dir / "ModOrganizer.exe" + prefix_service = AutomatedPrefixService() + conflict_result = prefix_service.handle_existing_shortcut_conflict( + shortcut_name, + str(candidate_exe), + str(install_dir), + ) + if isinstance(conflict_result, list): + action, new_name = prompt_existing_setup_dialog( + self, + window_title="Existing Modlist Setup Detected", + heading="Use Existing Setup or Create a New Shortcut", + body=( + "Jackify found an existing Steam shortcut for this Mod Organizer 2 setup.\n\n" + "Choose 'Use Existing Setup' to reuse the current Steam shortcut, or enter a " + "different name to create a separate shortcut." + ), + existing_name=conflict_result[0].get("name", shortcut_name), + requested_name=shortcut_name, + install_dir=str(install_dir), + field_label="New shortcut name", + reuse_label="Use Existing Setup", + new_label="Create New Shortcut", + cancel_label="Cancel", + ) + if action == "reuse": + existing_appid = conflict_result[0].get("appid") + if not existing_appid: + MessageService.warning(self, "Existing Setup Not Found", "Jackify could not determine the Steam AppID for the existing shortcut.") + return + self.console.append(f"Reusing existing Steam shortcut '{shortcut_name}'.") + elif action == "new": + if not new_name: + MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") + return + if new_name == shortcut_name: + MessageService.warning(self, "Same Name", "Please enter a different name to create a separate shortcut.") + return + shortcut_name = new_name + self.shortcut_name_edit.setText(new_name) + else: + self.console.append("Shortcut creation cancelled by user") + return + self.console.clear() self.file_progress_list.clear() self.file_progress_list.start_cpu_tracking() + from jackify.backend.handlers.logging_handler import LoggingHandler + log_handler = LoggingHandler() + log_handler.rotate_log_file_per_run(self.log_path, backup_count=5) + + self._write_to_log_file("=" * 60) + self._write_to_log_file("MO2 Setup Started") + self._write_to_log_file(f"Install directory: {install_dir}") + self._write_to_log_file(f"Shortcut name: {shortcut_name}") + if existing_appid: + self._write_to_log_file(f"Existing AppID: {existing_appid}") + self._write_to_log_file("=" * 60) + self.start_btn.setEnabled(False) - self.cancel_btn.setEnabled(False) + self.cancel_btn.setEnabled(True) + self.cancel_btn.setText("Cancel Setup") + self.shortcut_name_edit.setEnabled(False) + self.install_dir_edit.setEnabled(False) self.progress_indicator.set_status("Starting...", 0) - self.worker = MO2SetupWorker(install_dir, shortcut_name) + self.worker = MO2SetupWorker(install_dir, shortcut_name, int(existing_appid) if existing_appid else None) self.worker.progress_update.connect(self._on_progress_update) self.worker.progress_update.connect(self._on_activity_progress) self.worker.log_output.connect(self._on_log_output) @@ -356,14 +435,25 @@ class InstallMO2Screen(ScreenBackMixin, QWidget): def _on_progress_update(self, message: str): self.progress_indicator.set_status(message, 0) + if STEAM_RESTART_SENTINEL in message: + self._start_focus_reclaim_retries() def _on_log_output(self, message: str): + self._write_to_log_file(message) scrollbar = self.console.verticalScrollBar() was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 self.console.append(message) if was_at_bottom and not self._user_manually_scrolled: scrollbar.setValue(scrollbar.maximum()) + def _write_to_log_file(self, message: str): + try: + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(self.log_path, 'a', encoding='utf-8') as f: + f.write(f"[{timestamp}] {message}\n") + except Exception: + pass + def _on_setup_complete(self, success: bool, app_id, error_msg: str): self.file_progress_list.stop_cpu_tracking() @@ -384,6 +474,9 @@ class InstallMO2Screen(ScreenBackMixin, QWidget): self.start_btn.setEnabled(True) self.cancel_btn.setEnabled(True) + self.cancel_btn.setText("Cancel") + self.shortcut_name_edit.setEnabled(True) + self.install_dir_edit.setEnabled(True) if self.worker is not None: try: self.worker.deleteLater() @@ -429,22 +522,11 @@ class InstallMO2Screen(ScreenBackMixin, QWidget): self.file_progress_list.clear() self.console.clear() self.progress_indicator.set_status("Ready", 0) - if self.show_details_checkbox.isChecked(): - self.show_details_checkbox.blockSignals(True) - self.show_details_checkbox.setChecked(False) - self.show_details_checkbox.blockSignals(False) - self.console.setVisible(False) - self.resize_request.emit("collapse") + self.force_collapsed_details_state() def showEvent(self, event): super().showEvent(event) - # Keep MO2 screen consistent with other workflows: details collapsed by default. - if self.show_details_checkbox.isChecked(): - self.show_details_checkbox.blockSignals(True) - self.show_details_checkbox.setChecked(False) - self.show_details_checkbox.blockSignals(False) - self.console.setVisible(False) - self.resize_request.emit("collapse") + self.force_collapsed_details_state() try: main_window = self.window() if main_window: diff --git a/jackify/frontends/gui/screens/install_modlist.py b/jackify/frontends/gui/screens/install_modlist.py index ff73fbc..5d48eee 100644 --- a/jackify/frontends/gui/screens/install_modlist.py +++ b/jackify/frontends/gui/screens/install_modlist.py @@ -415,6 +415,10 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO """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 + 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) if thread is None: @@ -470,12 +474,19 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO pass setattr(self, attr_name, None) - # Always stop installer thread first; this is the most likely source of QThread teardown aborts. + # Always stop installer thread first; it needs cancel() not terminate(). _stop_thread('install_thread', cancel_method='cancel', cooperative_ms=15000, force_ms=10000) - # Stop remaining worker threads. - for thread_name in ('prefix_thread', 'config_thread', 'fetch_thread', '_gallery_cache_preload_thread'): - _stop_thread(thread_name) + # Stop any remaining QThread instances on this object, regardless of attribute name. + from PySide6.QtCore import QThread + for attr_name, value in list(vars(self).items()): + if attr_name == 'install_thread': + continue + try: + if isinstance(value, QThread): + _stop_thread(attr_name) + except Exception: + pass def cancel_installation(self): """Cancel the currently running installation""" @@ -499,6 +510,29 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO if hasattr(self, 'progress_indicator'): self.progress_indicator.set_status("Cancelled", None) + # Stop manual download manager and close dialog if active + if getattr(self, '_manual_dl_manager', None) is not None: + try: + self._manual_dl_manager.stop() + except Exception: + pass + self._manual_dl_manager = None + if getattr(self, '_manual_dl_dialog', None) is not None: + try: + self._manual_dl_dialog.close() + except Exception: + pass + self._manual_dl_dialog = None + if getattr(self, '_non_premium_info_dlg', None) is not None: + try: + self._non_premium_info_dlg.close() + except Exception: + pass + self._non_premium_info_dlg = None + self._non_premium_gate_enabled = False + self._non_premium_info_acknowledged = False + self._pending_manual_download_events = None + # Cancel the installation thread if it exists if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning(): self.install_thread.cancel() diff --git a/jackify/frontends/gui/screens/install_modlist_configuration.py b/jackify/frontends/gui/screens/install_modlist_configuration.py index 26547aa..4770293 100644 --- a/jackify/frontends/gui/screens/install_modlist_configuration.py +++ b/jackify/frontends/gui/screens/install_modlist_configuration.py @@ -1,6 +1,7 @@ """Configuration phase workflow for InstallModlistScreen (Mixin).""" from PySide6.QtWidgets import QMessageBox, QProgressDialog -from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtCore import Qt, QThread, Signal, QTimer +from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL from PySide6.QtGui import QFont from jackify.frontends.gui.services.message_service import MessageService from jackify.shared.errors import manual_steps_incomplete, configuration_failed @@ -16,7 +17,7 @@ import logging logger = logging.getLogger(__name__) from .install_modlist_shortcut_dialog import InstallModlistShortcutDialogMixin -class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): +class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMixin): """Mixin providing configuration phase workflow and dialog management for InstallModlistScreen.""" def on_configuration_progress(self, progress_msg): @@ -43,14 +44,9 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): pass finally: self.steam_restart_progress = None - # Controls are managed by the proper control management system - # Delay focus reclaim so Steam's window finishes painting before we steal it back - try: - from PySide6.QtCore import QTimer - win = self.window() - QTimer.singleShot(10000, lambda: (win.raise_(), win.activateWindow())) - except Exception: - pass + # Controls are managed by the proper control management system. + # Reclaim focus with bounded retries because Steam restart timing varies. + self._start_focus_reclaim_retries() def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str: """Detect game type by checking ModOrganizer.ini for loader executables.""" @@ -167,13 +163,20 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): # No VNV automation - end post-install feedback now self._end_post_install_feedback(True) + if getattr(self, "_is_update_install", False): + try: + self._verify_update_ini_after_configuration(install_dir) + except Exception as e: + logger.warning("Update mode verify: failed post-config INI verification: %s", e) + # Clear Activity window before showing success dialog self.file_progress_list.clear() # Show normal success dialog + workflow_type = "update" if getattr(self, "_is_update_install", False) else "install" success_dialog = SuccessDialog( modlist_name=modlist_name, - workflow_type="install", + workflow_type=workflow_type, time_taken=time_str, game_name=game_name, parent=self @@ -196,7 +199,19 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): else: # Configuration failed for other reasons self._end_post_install_feedback(False) - MessageService.show_error(self, configuration_failed("Post-install configuration failed.")) + MessageService.show_error( + self, + configuration_failed( + "Post-install configuration failed.", + context={ + "operation": "install_modlist", + "step": "post_install_configuration", + "modlist_name": modlist_name, + "install_dir": install_dir, + "workflow_type": "update" if getattr(self, "_is_update_install", False) else "install", + }, + ), + ) except Exception as e: # Ensure controls are re-enabled even on unexpected errors self._enable_controls_after_operation() @@ -206,7 +221,19 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): def on_configuration_error(self, error_message): """Handle configuration error on main thread""" self._safe_append_text(f"Configuration failed with error: {error_message}") - MessageService.show_error(self, configuration_failed(str(error_message))) + MessageService.show_error( + self, + configuration_failed( + str(error_message), + context={ + "operation": "install_modlist", + "step": "post_install_configuration", + "modlist_name": self.modlist_name_edit.text().strip(), + "install_dir": self.install_dir_edit.text().strip(), + "workflow_type": "update" if getattr(self, "_is_update_install", False) else "install", + }, + ), + ) # Re-enable all controls on error self._enable_controls_after_operation() diff --git a/jackify/frontends/gui/screens/install_modlist_console.py b/jackify/frontends/gui/screens/install_modlist_console.py index e5e108f..82303dd 100644 --- a/jackify/frontends/gui/screens/install_modlist_console.py +++ b/jackify/frontends/gui/screens/install_modlist_console.py @@ -155,49 +155,6 @@ class ConsoleOutputMixin: self._write_to_log_file(message) return - # CRITICAL: Detect token/auth errors and ALWAYS show them (even when not in debug mode) - token_error_keywords = [ - 'token has expired', - 'token expired', - 'oauth token', - 'authentication failed', - 'unauthorized', - '401', - '403', - 'refresh token', - 'authorization failed', - 'nexus.*premium.*required', - 'premium.*required', - ] - - is_token_error = any(keyword in msg_lower for keyword in token_error_keywords) - if is_token_error: - if not self._token_error_notified: - self._token_error_notified = True - MessageService.critical( - self, - "Authentication Error", - ( - "Nexus Mods authentication has failed. This may be due to:\n\n" - "• OAuth token expired and refresh failed\n" - "• Nexus Premium required for this modlist\n" - "• Network connectivity issues\n\n" - "Please check the console output (Show Details) for more information.\n" - "You may need to re-authorize in Settings." - ), - safety_level="high" - ) - # Also show in console - guidance = ( - "\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n" - "[Jackify] This may cause downloads to stop. Check the error message above.\n" - "[Jackify] If OAuth token expired, go to Settings and re-authorize.\n" - ) - self._safe_append_text(guidance) - # Force console to be visible so user can see the error - if not self.show_details_checkbox.isChecked(): - self.show_details_checkbox.setChecked(True) - # Detect known engine bugs and provide helpful guidance if 'destination array was not long enough' in msg_lower or \ ('argumentexception' in msg_lower and 'downloadmachineurl' in msg_lower): diff --git a/jackify/frontends/gui/screens/install_modlist_installer_thread.py b/jackify/frontends/gui/screens/install_modlist_installer_thread.py index 1979d3c..dce4c82 100644 --- a/jackify/frontends/gui/screens/install_modlist_installer_thread.py +++ b/jackify/frontends/gui/screens/install_modlist_installer_thread.py @@ -3,6 +3,7 @@ InstallerThread: QThread subclass for running jackify-engine install. Signals are defined at class level (required for Qt signal/slot). """ +import json import os import re import threading @@ -12,7 +13,8 @@ from PySide6.QtCore import QThread, Signal import logging from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code -from jackify.shared.errors import JackifyError +from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename +from jackify.shared.errors import JackifyError, cc_content_missing logger = logging.getLogger(__name__) @@ -24,6 +26,12 @@ class InstallerThread(QThread): progress_updated = Signal(object) installation_finished = Signal(bool, str) premium_required_detected = Signal(str) + # Emitted when engine outputs a full batch of manual download items. + # Payload: list of dicts with keys: file_name, nexus_url/download_url/url, + # expected_size, mod_name, mod_id, file_id, index, total, loop_iteration + manual_download_list_received = Signal(list) + manual_download_phase_complete = Signal() + non_premium_detected = Signal() 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): @@ -40,16 +48,81 @@ class InstallerThread(QThread): self.auth_service = auth_service self.oauth_info = oauth_info self._premium_signal_sent = False + self._non_premium_info_sent = False self._engine_output_buffer = [] self._buffer_size = 10 self.last_error: Optional[JackifyError] = None self._raw_stderr_lines: list = [] # bounded ring buffer for non-JSON stderr + self._raw_stdout_lines: list = [] # bounded ring buffer for non-JSON stdout + self._pending_manual_downloads: list = [] # accumulates items until list_complete + self._resource_limit_hint: Optional[str] = None + + @staticmethod + def _is_generic_failure_text(message: Optional[str]) -> bool: + text = (message or "").strip().lower() + if not text: + return True + generic_markers = ( + "did not complete successfully", + "unknown failure", + "an install engine error occurred", + "installation failed due to an engine error", + ) + return any(marker in text for marker in generic_markers) def cancel(self): self.cancelled = True if self.process_manager: self.process_manager.cancel() + def send_continue(self): + """Send the continue command to the engine after manual downloads are ready.""" + if self.process_manager: + sent = self.process_manager.write_stdin('{"command":"continue"}') + if sent: + logger.info("[MDL-1014] Manual download continue command accepted by process stdin") + else: + logger.error("[MDL-9010] Failed to send continue command to engine (stdin unavailable or process exited)") + + def _handle_engine_event(self, line: str) -> bool: + """ + Try to parse a stdout line as an engine workflow event. + Returns True if the line was an event (caller should not emit it as output). + """ + stripped = line.strip() + if not stripped.startswith('{'): + return False + try: + obj = json.loads(stripped) + except (json.JSONDecodeError, ValueError): + return False + + event = obj.get('event') + if not event: + return False + + if event == 'manual_download_required': + self._pending_manual_downloads.append(obj) + return True + + if event == 'manual_download_list_complete': + loop_iter = obj.get('loop_iteration', 1) + items = list(self._pending_manual_downloads) + self._pending_manual_downloads.clear() + for item in items: + item['loop_iteration'] = loop_iter + if items: + logger.info(f"[MDL-1000] Engine manual download list complete | loop_iteration={loop_iter} items={len(items)}") + self.manual_download_list_received.emit(items) + return True + + if event == 'manual_download_phase_complete': + logger.info("[MDL-1015] Engine reported manual download phase complete") + self.manual_download_phase_complete.emit() + return True + + return False + def _read_stderr(self): try: for raw in self.process_manager.proc.stderr: @@ -57,16 +130,116 @@ class InstallerThread(QThread): if not line: continue logger.debug(f"Engine stderr: {line}") + self._raw_stderr_lines.append(line) + if len(self._raw_stderr_lines) > 40: + self._raw_stderr_lines.pop(0) error = parse_engine_error_line(line) if error and self.last_error is None: self.last_error = error else: - self._raw_stderr_lines.append(line) - if len(self._raw_stderr_lines) > 20: - self._raw_stderr_lines.pop(0) + if self.last_error is None and is_cc_content_error(line): + self.last_error = cc_content_missing(extract_cc_filename(line) or "") except Exception as e: logger.debug(f"Stderr reader error: {e}") + def _remember_stdout_line(self, line: str) -> None: + """Keep a bounded tail of meaningful stdout lines for failure diagnostics.""" + cleaned = (line or "").strip() + if not cleaned: + return + if cleaned.startswith("{"): + return + if cleaned.startswith("Installing files ") or cleaned.startswith("Extracting files "): + return + self._raw_stdout_lines.append(cleaned) + if len(self._raw_stdout_lines) > 60: + self._raw_stdout_lines.pop(0) + + def _extract_root_cause_line(self) -> Optional[str]: + """Extract the most actionable error line from stderr/stdout tails.""" + combined = list(reversed(self._raw_stderr_lines)) + list(reversed(self._raw_stdout_lines)) + if not combined: + return None + + ignore_fragments = ( + "installation failed", + "install failed", + "exit code", + "building bsa", + "generating debug caches", + ) + priority_fragments = ( + "too many open files", + "file descriptor", + "resource temporarily unavailable", + "cannot increase file descriptor limit", + "permission denied", + "no space left on device", + "traceback", + "fatal", + "exception", + "error", + "failed", + "could not", + "unable to", + ) + + for raw in combined: + lowered = raw.lower() + if any(fragment in lowered for fragment in ignore_fragments): + continue + if any(fragment in lowered for fragment in priority_fragments): + return raw + + for raw in combined: + lowered = raw.lower() + if any(fragment in lowered for fragment in ignore_fragments): + continue + return raw + + return None + + def _build_failure_message(self, returncode: int) -> str: + """Build a user-facing failure message with the best available root cause.""" + root_cause = self._extract_root_cause_line() + if root_cause: + if self._resource_limit_hint and "file descriptor" not in root_cause.lower(): + return f"{root_cause}\n\nPossible contributing issue: {self._resource_limit_hint}" + return root_cause + + recent_lines = [] + for line in list(reversed(self._raw_stderr_lines)) + list(reversed(self._raw_stdout_lines)): + cleaned = (line or "").strip() + if not cleaned: + continue + lowered = cleaned.lower() + if ( + "install failed" in lowered + or "installation failed" in lowered + or "exit code" in lowered + or "building bsa" in lowered + or "generating debug caches" in lowered + ): + continue + if cleaned not in recent_lines: + recent_lines.append(cleaned) + if len(recent_lines) >= 3: + break + + if recent_lines: + recent_block = "\n- ".join(recent_lines) + return ( + "Install engine reported errors.\n\n" + f"Most recent engine output:\n- {recent_block}" + ) + + if self._resource_limit_hint: + return self._resource_limit_hint + + return ( + "Install failed, but the engine did not provide a specific error line." + ) + def run(self): try: from jackify.backend.core.modlist_operations import get_jackify_engine_path @@ -101,8 +274,25 @@ class InstallerThread(QThread): from jackify.backend.services.nexus_oauth_service import NexusOAuthService env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID env = get_clean_subprocess_env(env_vars) + + # Install-time resource preflight: keep this visible in workflow output so + # users/support see hard-limit constraints even without debug logging. + try: + from jackify.backend.services.resource_manager import ResourceManager + resource_manager = ResourceManager() + status = resource_manager.get_limit_status() + if status.get('current_hard', 0) < status.get('target_limit', 0): + self._resource_limit_hint = ( + f"File descriptor hard limit is {status['current_hard']} " + f"(target {status['target_limit']}); this can cause install failures. " + "Increase ulimit and retry." + ) + self.output_received.emit(f"[WARN] {self._resource_limit_hint}\n") + except Exception as e: + logger.debug(f"Resource preflight check failed: {e}") + from jackify.backend.handlers.subprocess_utils import ProcessManager - self.process_manager = ProcessManager(cmd, env=env, text=False, separate_stderr=True) + self.process_manager = ProcessManager(cmd, env=env, text=False, separate_stderr=True, enable_stdin=True) stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) stderr_thread.start() ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]') @@ -161,6 +351,8 @@ class InstallerThread(QThread): self._engine_output_buffer.append(decoded.strip()) if len(self._engine_output_buffer) > self._buffer_size: self._engine_output_buffer.pop(0) + if self.last_error is None and is_cc_content_error(decoded): + self.last_error = cc_content_missing(extract_cc_filename(decoded) or "") if self.progress_state_manager: updated = self.progress_state_manager.process_line(decoded) if updated: @@ -179,7 +371,7 @@ class InstallerThread(QThread): line = ansi_escape.sub(b'', line) decoded = line.decode('utf-8', errors='replace') from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator - is_premium_error, matched_pattern = is_non_premium_indicator(decoded) + is_premium_error, matched_pattern = (False, None) if decoded.strip().startswith('{') else is_non_premium_indicator(decoded) if not self._premium_signal_sent and is_premium_error: self._premium_signal_sent = True logger.warning("=" * 80) @@ -213,9 +405,14 @@ class InstallerThread(QThread): logger.warning("If user HAS Premium, this is a FALSE POSITIVE") logger.warning("=" * 80) self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") + if not self._non_premium_info_sent and 'non-premium' in decoded.lower() and 'routing' in decoded.lower(): + self._non_premium_info_sent = True + self.non_premium_detected.emit() self._engine_output_buffer.append(decoded.strip()) if len(self._engine_output_buffer) > self._buffer_size: self._engine_output_buffer.pop(0) + if self.last_error is None and is_cc_content_error(decoded): + self.last_error = cc_content_missing(extract_cc_filename(decoded) or "") config_handler = ConfigHandler() debug_mode = config_handler.get('debug_mode', False) if self.progress_state_manager: @@ -225,6 +422,10 @@ class InstallerThread(QThread): if progress_state.active_files and debug_mode: logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}") self.progress_updated.emit(progress_state) + if self._handle_engine_event(decoded): + last_was_blank = False + continue + self._remember_stdout_line(decoded) if '[FILE_PROGRESS]' in decoded: parts = decoded.split('[FILE_PROGRESS]', 1) if parts[0].strip(): @@ -246,6 +447,7 @@ class InstallerThread(QThread): if parts[0].strip(): self.output_received.emit(parts[0].rstrip()) else: + self._remember_stdout_line(decoded) self.output_received.emit(decoded) stderr_thread.join(timeout=5) returncode = self.process_manager.wait() @@ -265,14 +467,18 @@ class InstallerThread(QThread): except Exception as e: logger.debug(f"DEBUG: Error reading remaining output: {e}") if returncode != 0 and not self.cancelled and self.last_error is None: - stderr_detail = "\n".join(self._raw_stderr_lines[-10:]) if self._raw_stderr_lines else "" - detail = f"Exit code {returncode}.\n\nEngine output:\n{stderr_detail}" if stderr_detail else f"Exit code {returncode}." + stderr_tail = self._raw_stderr_lines[-10:] if self._raw_stderr_lines else [] + stdout_tail = self._raw_stdout_lines[-10:] if self._raw_stdout_lines else [] + combined_tail = stderr_tail + stdout_tail + tail_text = "\n".join(combined_tail) + detail = f"Exit code {returncode}.\n\nEngine output:\n{tail_text}" if tail_text else f"Exit code {returncode}." fallback = error_from_exit_code( returncode, detail, context={ "exit_code": returncode, - "stderr_tail_lines": len(self._raw_stderr_lines[-10:]), + "stderr_tail_lines": len(stderr_tail), + "stdout_tail_lines": len(stdout_tail), }, ) if fallback: @@ -283,8 +489,14 @@ class InstallerThread(QThread): elif returncode == 0: self.installation_finished.emit(True, "Installation completed successfully") else: - error_msg = f"Installation failed (exit code {returncode})" - logger.debug(f"DEBUG: Engine exited with code {returncode}") + if self.last_error: + error_msg = self.last_error.message or "" + if self._is_generic_failure_text(error_msg): + error_msg = self._build_failure_message(returncode) + self.last_error.message = error_msg + else: + error_msg = self._build_failure_message(returncode) + logger.error(f"Engine install failed | exit_code={returncode} summary={error_msg}") self.installation_finished.emit(False, error_msg) except Exception as e: self.installation_finished.emit(False, f"Installation error: {str(e)}") diff --git a/jackify/frontends/gui/screens/install_modlist_nexus.py b/jackify/frontends/gui/screens/install_modlist_nexus.py index f8061ce..a7f76f9 100644 --- a/jackify/frontends/gui/screens/install_modlist_nexus.py +++ b/jackify/frontends/gui/screens/install_modlist_nexus.py @@ -1,6 +1,6 @@ """Nexus authentication methods for InstallModlistScreen (Mixin).""" -from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QProgressDialog, QApplication -from PySide6.QtCore import Qt, QTimer, QThread, Signal +from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QApplication +from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QDesktopServices, QGuiApplication import logging import webbrowser @@ -47,7 +47,6 @@ class NexusAuthMixin: layout = QVBoxLayout() layout.setSpacing(15) - # Explanation label info_label = QLabel( "Could not open browser automatically.\n\n" "Please copy the URL below and paste it into your browser:" @@ -56,11 +55,10 @@ class NexusAuthMixin: info_label.setStyleSheet("color: #ccc; font-size: 12px;") layout.addWidget(info_label) - # URL input (read-only but selectable) url_input = QLineEdit() url_input.setText(url) url_input.setReadOnly(True) - url_input.selectAll() # Pre-select text for easy copying + url_input.selectAll() url_input.setStyleSheet(""" QLineEdit { background-color: #1a1a1a; @@ -74,11 +72,9 @@ class NexusAuthMixin: """) layout.addWidget(url_input) - # Button row button_layout = QHBoxLayout() button_layout.addStretch() - # Copy button copy_btn = QPushButton("Copy URL") copy_btn.setStyleSheet(""" QPushButton { @@ -101,7 +97,6 @@ class NexusAuthMixin: copy_btn.clicked.connect(copy_to_clipboard) button_layout.addWidget(copy_btn) - # Close button close_btn = QPushButton("Close") close_btn.setStyleSheet(""" QPushButton { @@ -119,7 +114,113 @@ class NexusAuthMixin: button_layout.addWidget(close_btn) layout.addLayout(button_layout) + dialog.setLayout(layout) + dialog.exec() + def _show_oauth_paste_dialog(self): + """Show dialog for pasting jackify:// callback URL as manual fallback.""" + import urllib.parse + from pathlib import Path + + dialog = QDialog(self) + dialog.setWindowTitle("Paste Callback URL") + dialog.setModal(True) + dialog.setMinimumWidth(560) + + layout = QVBoxLayout() + layout.setSpacing(12) + layout.setContentsMargins(20, 20, 20, 20) + + info_label = QLabel( + "If your browser did not complete the flow automatically:\n\n" + "1. Click Continue in your browser if you have not already.\n" + "2. If a URL starting with jackify:// appears in your browser\n" + " address bar, copy it and paste it below." + ) + info_label.setWordWrap(True) + info_label.setStyleSheet("color: #ccc; font-size: 12px;") + layout.addWidget(info_label) + + url_input = QLineEdit() + url_input.setPlaceholderText("jackify://oauth/callback?code=...&state=...") + url_input.setStyleSheet(""" + QLineEdit { + background-color: #1a1a1a; + color: #3fd0ea; + border: 1px solid #444; + border-radius: 4px; + padding: 8px; + font-family: monospace; + font-size: 11px; + } + """) + layout.addWidget(url_input) + + error_label = QLabel("") + error_label.setStyleSheet("color: #f44336; font-size: 11px;") + error_label.setWordWrap(True) + layout.addWidget(error_label) + + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + submit_btn = QPushButton("Submit") + submit_btn.setStyleSheet(""" + QPushButton { + background-color: #3fd0ea; + color: #000; + border: none; + border-radius: 4px; + padding: 8px 20px; + font-weight: bold; + } + QPushButton:hover { + background-color: #5fdfff; + } + """) + + def on_submit(): + url = url_input.text().strip() + if not url.startswith('jackify://oauth/callback'): + error_label.setText("URL must start with jackify://oauth/callback") + return + parsed = urllib.parse.urlparse(url) + params = urllib.parse.parse_qs(parsed.query) + code = params.get('code', [None])[0] + state = params.get('state', [None])[0] + if not code or not state: + error_label.setText("URL is missing required code or state parameter.") + return + callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp" + try: + callback_file.parent.mkdir(parents=True, exist_ok=True) + callback_file.write_text(f"{code}\n{state}") + logger.info("OAuth callback written via manual paste") + dialog.accept() + except Exception as e: + error_label.setText(f"Failed to write callback: {e}") + + submit_btn.clicked.connect(on_submit) + url_input.returnPressed.connect(on_submit) + btn_layout.addWidget(submit_btn) + + cancel_btn = QPushButton("Cancel") + cancel_btn.setStyleSheet(""" + QPushButton { + background-color: #444; + color: #ccc; + border: none; + border-radius: 4px; + padding: 8px 20px; + } + QPushButton:hover { + background-color: #555; + } + """) + cancel_btn.clicked.connect(dialog.reject) + btn_layout.addWidget(cancel_btn) + + layout.addLayout(btn_layout) dialog.setLayout(layout) dialog.exec() @@ -129,13 +230,11 @@ class NexusAuthMixin: authenticated, method, _ = self.auth_service.get_auth_status() if authenticated and method == 'oauth': - # OAuth is active - offer to revoke reply = MessageService.question(self, "Revoke", "Revoke OAuth authorisation?", safety_level="low") if reply == QMessageBox.Yes: self.auth_service.revoke_oauth() self._update_nexus_status() else: - # Not authorised or using API key - offer to authorise with OAuth reply = MessageService.question(self, "Authorise with Nexus", "Your browser will open for Nexus authorisation.\n\n" "Note: Your browser may ask permission to open 'xdg-open'\n" @@ -146,33 +245,82 @@ class NexusAuthMixin: if reply != QMessageBox.Yes: return - # Create progress dialog - progress = QProgressDialog( - "Waiting for authorisation...\n\nPlease check your browser.", - "Cancel", - 0, 0, - self - ) - progress.setWindowTitle("Nexus OAuth") - progress.setWindowModality(Qt.WindowModal) - progress.setMinimumDuration(0) - progress.setMinimumWidth(400) + # Build waiting dialog with paste fallback always accessible + wait_dialog = QDialog(self) + wait_dialog.setWindowTitle("Nexus OAuth") + wait_dialog.setWindowModality(Qt.WindowModal) + wait_dialog.setMinimumWidth(420) + + wait_layout = QVBoxLayout() + wait_layout.setSpacing(12) + wait_layout.setContentsMargins(20, 20, 20, 20) + + 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." + ) + wait_label.setWordWrap(True) + wait_label.setStyleSheet("color: #ccc; font-size: 12px;") + wait_layout.addWidget(wait_label) + + wait_layout.addStretch() + + btn_layout = QHBoxLayout() + + paste_btn = QPushButton("Paste callback URL") + paste_btn.setToolTip( + "If your browser shows a jackify:// URL after clicking Continue, paste it here." + ) + paste_btn.setStyleSheet(""" + QPushButton { + background-color: #333; + color: #aaa; + border: 1px solid #555; + border-radius: 4px; + padding: 8px 16px; + } + QPushButton:hover { + background-color: #444; + color: #ccc; + } + """) + paste_btn.clicked.connect(self._show_oauth_paste_dialog) + btn_layout.addWidget(paste_btn) + + btn_layout.addStretch() - # Track cancellation oauth_cancelled = [False] - def on_cancel(): + cancel_btn = QPushButton("Cancel") + cancel_btn.setStyleSheet(""" + QPushButton { + background-color: #444; + color: #ccc; + border: none; + border-radius: 4px; + padding: 8px 16px; + } + QPushButton:hover { + background-color: #555; + } + """) + def on_cancel_click(): oauth_cancelled[0] = True + wait_dialog.close() + cancel_btn.clicked.connect(on_cancel_click) + btn_layout.addWidget(cancel_btn) - progress.canceled.connect(on_cancel) - progress.show() + wait_layout.addLayout(btn_layout) + wait_dialog.setLayout(wait_layout) + wait_dialog.show() QApplication.processEvents() # Create OAuth thread to prevent GUI freeze class OAuthThread(QThread): finished_signal = Signal(bool) message_signal = Signal(str) - manual_url_signal = Signal(str) # Signal when browser fails to open + manual_url_signal = Signal(str) def __init__(self, auth_service, parent=None): super().__init__(parent) @@ -180,9 +328,7 @@ class NexusAuthMixin: def run(self): def show_message(msg): - # Check if this is a "browser failed" message with URL if "Could not open browser" in msg and "Please open this URL manually:" in msg: - # Extract URL from message url_start = msg.find("Please open this URL manually:") + len("Please open this URL manually:") url = msg[url_start:].strip() self.manual_url_signal.emit(url) @@ -194,23 +340,20 @@ class NexusAuthMixin: oauth_thread = OAuthThread(self.auth_service, self) - # Connect message signal to update progress dialog def update_progress_message(msg): if not oauth_cancelled[0]: - progress.setLabelText(f"Waiting for authorisation...\n\n{msg}") + wait_label.setText(f"Waiting for authorisation...\n\n{msg}") QApplication.processEvents() - # Connect manual URL signal to show copyable dialog def show_manual_url_dialog(url): if not oauth_cancelled[0]: - progress.hide() # Hide progress dialog temporarily + wait_dialog.hide() self._show_copyable_url_dialog(url) - progress.show() + wait_dialog.show() oauth_thread.message_signal.connect(update_progress_message) oauth_thread.manual_url_signal.connect(show_manual_url_dialog) - # Wait for thread completion oauth_success = [False] def on_oauth_finished(success): oauth_success[0] = success @@ -218,25 +361,21 @@ class NexusAuthMixin: oauth_thread.finished_signal.connect(on_oauth_finished) oauth_thread.start() - # Wait for thread to finish (non-blocking event loop) while oauth_thread.isRunning(): QApplication.processEvents() - oauth_thread.wait(100) # Check every 100ms + oauth_thread.wait(100) if oauth_cancelled[0]: - # User cancelled - thread will still complete but we ignore result oauth_thread.wait(2000) if oauth_thread.isRunning(): oauth_thread.terminate() break - progress.close() + wait_dialog.close() QApplication.processEvents() self._update_nexus_status() self._enable_controls_after_operation() - # Check success first - if OAuth succeeded, ignore cancellation flag - # (progress dialog close can trigger cancel handler even on success) if oauth_success[0]: _, _, username = self.auth_service.get_auth_status() if username: @@ -250,11 +389,10 @@ class NexusAuthMixin: MessageService.warning( self, "Authorisation Failed", - "OAuth authorisation failed.\n\n" - "If your browser showed a blank page (e.g. Firefox on Steam Deck),\n" - "try again and use 'Paste callback URL' to paste the URL from the address bar.\n\n" - "If you see 'redirect URI mismatch', the OAuth redirect URI must be configured by Nexus.\n\n" - "You can configure an API key in Settings as a fallback.", + "OAuth authorisation timed out.\n\n" + "If your browser shows a URL starting with jackify:// after\n" + "clicking Continue, try again and use 'Paste callback URL'\n" + "during the wait to complete authorisation manually.\n\n" + "If the issue persists, an API key can be configured in Settings.", safety_level="medium" ) - diff --git a/jackify/frontends/gui/screens/install_modlist_postinstall.py b/jackify/frontends/gui/screens/install_modlist_postinstall.py index aca9a0f..10dd293 100644 --- a/jackify/frontends/gui/screens/install_modlist_postinstall.py +++ b/jackify/frontends/gui/screens/install_modlist_postinstall.py @@ -229,6 +229,8 @@ class PostInstallFeedbackMixin: prev_step = self._post_install_sequence[self._post_install_current_step - 1] if prev_step['id'] == 'wine_components' and step['id'] != 'wine_components': self._stop_component_install_pulse() + if prev_step['id'] == 'vnv_bsa_decompress' and step['id'] != 'vnv_bsa_decompress': + self._stop_bsa_decompress_pulse() self._post_install_current_step = idx self._post_install_last_label = step['label'] @@ -250,6 +252,9 @@ class PostInstallFeedbackMixin: self._start_component_install_pulse_with_components(comp_list) break + if step['id'] == 'vnv_bsa_decompress': + self._start_bsa_decompress_pulse() + # Keep Activity window in sync with progress banner # If we're already in wine_components step, check for component list updates # Skip _update_post_install_ui() for wine_components - pulser manages Activity window directly @@ -402,6 +407,7 @@ class PostInstallFeedbackMixin: if not self._post_install_active: return self._stop_component_install_pulse() + self._stop_bsa_decompress_pulse() total = max(1, self._post_install_total_steps) final_step = total if success else max(0, self._post_install_current_step) label = "Post-installation complete" if success else "Post-installation stopped" @@ -468,3 +474,18 @@ class PostInstallFeedbackMixin: if hasattr(self, '_component_install_list'): del self._component_install_list + def _start_bsa_decompress_pulse(self): + """Keep the Activity window alive during long BSA decompression runs.""" + self.file_progress_list.update_or_add_item("__vnv_bsa__", "VNV: Decompressing BSA files...", 0.0) + if not getattr(self, '_bsa_decompress_timer', None): + self._bsa_decompress_timer = QTimer(self) + self._bsa_decompress_timer.timeout.connect(self._bsa_decompress_heartbeat) + self._bsa_decompress_timer.start(250) + + def _bsa_decompress_heartbeat(self): + self.file_progress_list.update_or_add_item("__vnv_bsa__", "VNV: Decompressing BSA files...", 0.0) + + def _stop_bsa_decompress_pulse(self): + if hasattr(self, '_bsa_decompress_timer') and self._bsa_decompress_timer: + self._bsa_decompress_timer.stop() + self._bsa_decompress_timer = None diff --git a/jackify/frontends/gui/screens/install_modlist_progress.py b/jackify/frontends/gui/screens/install_modlist_progress.py index 3ec1429..5e055b9 100644 --- a/jackify/frontends/gui/screens/install_modlist_progress.py +++ b/jackify/frontends/gui/screens/install_modlist_progress.py @@ -8,6 +8,7 @@ from jackify.shared.progress_models import InstallationPhase, OperationType, Ins from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator import time import logging +import os logger = logging.getLogger(__name__) class ProgressHandlersMixin: @@ -53,6 +54,52 @@ class ProgressHandlersMixin: if hasattr(self, 'install_thread') and self.install_thread: self.install_thread.cancel() + def on_non_premium_detected(self): + """Gate the manual-download dialog until non-premium info has been acknowledged.""" + self._non_premium_gate_enabled = True + self._non_premium_info_acknowledged = False + logger.info("[MDL-1002] Non-premium flow detected; info dialog will show when manual downloads arrive") + + def _show_non_premium_info_dialog(self): + """Show the non-premium information dialog. Blocks (nested event loop) until user clicks OK. + + Called from on_manual_download_list_received, so it only appears when files actually + need manual downloading. The engine is paused waiting for a continue signal at that + point, so process_finished will not fire and close the dialog prematurely. + """ + from PySide6.QtCore import Qt + if getattr(self, '_non_premium_info_dlg', None) is not None: + return + if getattr(self, '_non_premium_info_acknowledged', False): + return + + box = QMessageBox(self) + box.setWindowTitle("Non-Premium Account Detected") + box.setIcon(QMessageBox.Information) + box.setWindowModality(Qt.WindowModal) + box.setTextFormat(Qt.RichText) + box.setText( + "Jackify has detected that your Nexus account does not have Premium." + "

" + "The install will proceed in the following stages:" + "
    " + "
  1. Automatically download any mods available from non-Nexus sources
  2. " + "
  3. After you click OK here, open a manual download dialog listing all remaining manual archives
  4. " + "
" + "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 — " + "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." + ) + box.setStandardButtons(QMessageBox.Ok) + self._non_premium_info_dlg = box + box.exec() + self._non_premium_info_dlg = None + self._non_premium_info_acknowledged = True + logger.info("[MDL-1003] Non-premium information dialog acknowledged by user") + def on_progress_updated(self, progress_state): """R&D: Handle structured progress updates from parser""" # Calculate proper overall progress during BSA building @@ -321,11 +368,16 @@ class ProgressHandlersMixin: from jackify.backend.utils.modlist_meta import write_modlist_meta thread = getattr(self, 'install_thread', None) if thread and getattr(thread, 'install_dir', None) and getattr(thread, 'modlist_name', None): + modlist_version = None + if getattr(thread, 'install_mode', 'online') == 'online': + info = getattr(self, 'selected_modlist_info', None) or {} + modlist_version = info.get('version') write_modlist_meta( thread.install_dir, thread.modlist_name, getattr(self, '_current_game_type', None), install_mode=getattr(thread, 'install_mode', 'online'), + modlist_version=modlist_version, ) except Exception as _meta_err: logger.debug(f"Modlist meta write skipped: {_meta_err}") @@ -337,6 +389,19 @@ class ProgressHandlersMixin: else: # Reset to initial state on failure self.progress_indicator.reset() + cancellation_detected = ( + (isinstance(message, str) and "cancelled by user" in message.lower()) + or bool(getattr(self, '_cancellation_requested', False)) + ) + if cancellation_detected: + self._installation_cancelled = True + logger.info("Installation cancelled by user") + if self.show_details_checkbox.isChecked(): + self._safe_append_text("\nInstallation cancelled by user.") + # Use a distinct non-success code and let process_finished route this + # through the cancellation UX path (not failure path). + self.process_finished(130, QProcess.NormalExit) + return if self._premium_failure_active: message = "Installation stopped because Nexus Premium is required for automated downloads." @@ -359,9 +424,38 @@ class ProgressHandlersMixin: self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) logger.debug("DEBUG: Button states reset in process_finished") + + # Stop manual download manager if it is still running (e.g. install failed mid-phase) + if getattr(self, '_manual_dl_manager', None) is not None: + try: + self._manual_dl_manager.stop() + except Exception: + pass + self._manual_dl_manager = None + if getattr(self, '_manual_dl_dialog', None) is not None: + try: + self._manual_dl_dialog.close() + except Exception: + pass + self._manual_dl_dialog = None + if getattr(self, '_non_premium_info_dlg', None) is not None: + try: + self._non_premium_info_dlg.close() + except Exception: + pass + self._non_premium_info_dlg = None + self._non_premium_gate_enabled = False + self._non_premium_info_acknowledged = False + self._pending_manual_download_events = None if exit_code == 0: + if getattr(self, "_is_update_install", False): + try: + install_dir = os.path.realpath(self.install_dir_edit.text().strip()) + self._record_post_engine_ini_snapshot_and_diff(install_dir) + except Exception as e: + logger.warning("Update mode: failed post-engine MO2 snapshot/diff: %s", e) # Check if this was an unsupported game game_type = getattr(self, '_current_game_type', None) game_name = getattr(self, '_current_game_name', None) @@ -395,9 +489,22 @@ class ProgressHandlersMixin: ) if reply == QMessageBox.Yes: - # --- Create Steam shortcut BEFORE restarting Steam --- - # Proceed directly to automated prefix creation - self.start_automated_prefix_workflow() + if getattr(self, "_is_update_install", False) and getattr(self, "_existing_shortcut_appid", None): + # Update workflow: reuse existing shortcut and skip shortcut creation/restart path. + modlist_name = self.modlist_name_edit.text().strip() + install_dir = os.path.realpath(self.install_dir_edit.text().strip()) + self._safe_append_text( + f"Update mode: reusing existing Steam shortcut AppID {self._existing_shortcut_appid}." + ) + self.continue_configuration_after_automated_prefix( + self._existing_shortcut_appid, + modlist_name, + install_dir, + None, + ) + else: + # New install workflow: create shortcut and run automated prefix flow. + self.start_automated_prefix_workflow() else: # User selected "No" - show completion message and keep GUI open self._safe_append_text("\nModlist installation completed successfully!") @@ -424,6 +531,10 @@ class ProgressHandlersMixin: logger.warning("Install stopped: Nexus Premium required") self._safe_append_text("\nInstall stopped: Nexus Premium required.") self._premium_failure_active = False + elif getattr(self, '_installation_cancelled', False): + MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") + self._installation_cancelled = False + self._cancellation_requested = False elif hasattr(self, '_cancellation_requested') and self._cancellation_requested: # User explicitly cancelled via cancel button MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") @@ -434,14 +545,39 @@ class ProgressHandlersMixin: if "cancelled by user" in last_output.lower(): MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") else: - logger.error(f"Install failed (exit code {exit_code})") engine_error = getattr(self, '_engine_error', None) if engine_error: self._engine_error = None + logger.error( + "Install failed | exit_code=%s error=%s", + exit_code, + engine_error.message, + ) MessageService.show_error(self, engine_error) + self._safe_append_text(f"\nInstall failed: {engine_error.message}") else: - failure_msg = getattr(self, '_failure_message', None) or f"Exit code {exit_code}." + failure_msg = ( + getattr(self, '_failure_message', None) + or "Install failed, but no specific error details were captured from engine output." + ) self._failure_message = None - MessageService.show_error(self, wabbajack_install_failed(failure_msg)) - self._safe_append_text(f"\nInstall failed (exit code {exit_code}).") + logger.error( + "Install failed | exit_code=%s summary=%s", + exit_code, + failure_msg, + ) + MessageService.show_error( + self, + wabbajack_install_failed( + failure_msg, + context={ + "operation": "install_modlist", + "step": "engine_install", + "exit_code": exit_code, + "modlist_name": self.modlist_name_edit.text().strip(), + "install_dir": self.install_dir_edit.text().strip(), + }, + ), + ) + self._safe_append_text(f"\nInstall failed: {failure_msg}") self.console.moveCursor(QTextCursor.End) diff --git a/jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py b/jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py index 3a0890e..b1b3455 100644 --- a/jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py +++ b/jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py @@ -1,111 +1,83 @@ -"""Steam shortcut conflict dialog and retry workflow for InstallModlistScreen (Mixin).""" -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QLabel, - QLineEdit, - QPushButton, - QHBoxLayout, -) +"""Steam shortcut conflict handling for InstallModlistScreen (Mixin).""" +import os + +from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog from jackify.frontends.gui.services.message_service import MessageService class InstallModlistShortcutDialogMixin: """Mixin providing shortcut conflict dialog and retry-with-new-name for InstallModlistScreen.""" + def _restore_controls_after_shortcut_dialog_abort(self): + """Return Install Modlist to a usable state when shortcut resolution is aborted.""" + if hasattr(self, "_abort_install_validation"): + try: + self._abort_install_validation() + return + except Exception: + pass + try: + self._enable_controls_after_operation() + except Exception: + pass + try: + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + except Exception: + pass + def show_shortcut_conflict_dialog(self, conflicts): - """Show dialog to resolve shortcut name conflicts.""" - conflict_names = [c['name'] for c in conflicts] - conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'" - + """Show dialog to resolve existing install / shortcut conflicts.""" + existing_name = conflicts[0].get("name") or self.modlist_name_edit.text().strip() modlist_name = self.modlist_name_edit.text().strip() + install_dir = os.path.realpath(self.install_dir_edit.text().strip()) - dialog = QDialog(self) - dialog.setWindowTitle("Steam Shortcut Conflict") - dialog.setModal(True) - dialog.resize(450, 180) + action, new_name = prompt_existing_setup_dialog( + self, + window_title="Existing Modlist Setup Detected", + heading="Modlist Update or New Install", + body=( + "Jackify detected an existing Steam shortcut for this modlist setup.\n\n" + "If you are updating, repairing, or reconfiguring an existing install, choose " + "'Use Existing Setup'. If you want a separate Steam entry, enter a different " + "name and choose 'Create New Shortcut'." + ), + existing_name=existing_name, + requested_name=modlist_name, + install_dir=install_dir, + field_label="New shortcut name", + reuse_label="Use Existing Setup", + new_label="Create New Shortcut", + cancel_label="Cancel", + ) - dialog.setStyleSheet(""" - QDialog { - background-color: #2b2b2b; - color: #ffffff; - } - QLabel { - color: #ffffff; - font-size: 14px; - padding: 10px 0px; - } - QLineEdit { - background-color: #404040; - color: #ffffff; - border: 2px solid #555555; - border-radius: 4px; - padding: 8px; - font-size: 14px; - selection-background-color: #3fd0ea; - } - QLineEdit:focus { - border-color: #3fd0ea; - } - QPushButton { - background-color: #404040; - color: #ffffff; - border: 2px solid #555555; - border-radius: 4px; - padding: 8px 16px; - font-size: 14px; - min-width: 120px; - } - QPushButton:hover { - background-color: #505050; - border-color: #3fd0ea; - } - QPushButton:pressed { - background-color: #303030; - } - """) + if action == "reuse": + existing_appid = conflicts[0].get("appid") + if not existing_appid: + MessageService.warning( + self, + "Existing Setup Not Found", + "Jackify could not determine the Steam AppID for the existing shortcut.", + ) + self._restore_controls_after_shortcut_dialog_abort() + return + self._safe_append_text(f"Reusing existing Steam shortcut '{existing_name}'.") + self.continue_configuration_after_automated_prefix(int(existing_appid), modlist_name, install_dir, None) + return - layout = QVBoxLayout(dialog) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(15) - - conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:") - layout.addWidget(conflict_label) - - name_input = QLineEdit(modlist_name) - name_input.selectAll() - layout.addWidget(name_input) - - button_layout = QHBoxLayout() - button_layout.setSpacing(10) - - create_button = QPushButton("Create with New Name") - cancel_button = QPushButton("Cancel") - - button_layout.addStretch() - button_layout.addWidget(cancel_button) - button_layout.addWidget(create_button) - layout.addLayout(button_layout) - - def on_create(): - new_name = name_input.text().strip() + if action == "new": if new_name and new_name != modlist_name: - dialog.accept() self.retry_automated_workflow_with_new_name(new_name) - elif new_name == modlist_name: + return + if new_name == modlist_name: MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") else: MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") + self._restore_controls_after_shortcut_dialog_abort() + return - def on_cancel(): - dialog.reject() - self._safe_append_text("Shortcut creation cancelled by user") - - create_button.clicked.connect(on_create) - cancel_button.clicked.connect(on_cancel) - name_input.returnPressed.connect(on_create) - - dialog.exec() + self._safe_append_text("Shortcut creation cancelled by user") + self._restore_controls_after_shortcut_dialog_abort() def retry_automated_workflow_with_new_name(self, new_name): """Retry the automated workflow with a new shortcut name.""" diff --git a/jackify/frontends/gui/screens/install_modlist_ui_setup.py b/jackify/frontends/gui/screens/install_modlist_ui_setup.py index bfddabc..2c7baba 100644 --- a/jackify/frontends/gui/screens/install_modlist_ui_setup.py +++ b/jackify/frontends/gui/screens/install_modlist_ui_setup.py @@ -69,6 +69,11 @@ class InstallModlistUISetupMixin: self.file_progress_list = FileProgressList() # Shows all active files (scrolls if needed) self._premium_notice_shown = False self._premium_failure_active = False + self._installation_cancelled = False + self._non_premium_gate_enabled = False + self._non_premium_info_acknowledged = False + self._pending_manual_download_events = None + self._non_premium_info_dlg = None self._stalled_download_start_time = None self._stalled_download_notified = False self._stalled_data_snapshot = 0 @@ -509,4 +514,3 @@ class InstallModlistUISetupMixin: # Now collect all actionable controls after UI is fully built self._collect_actionable_controls() - diff --git a/jackify/frontends/gui/screens/install_modlist_vnv.py b/jackify/frontends/gui/screens/install_modlist_vnv.py index 76b98f4..9aaee40 100644 --- a/jackify/frontends/gui/screens/install_modlist_vnv.py +++ b/jackify/frontends/gui/screens/install_modlist_vnv.py @@ -1,9 +1,9 @@ -"""VNV automation methods for InstallModlistScreen (Mixin).""" -from pathlib import Path -from PySide6.QtCore import QTimer +"""VNV automation methods for InstallModlistScreen (Mixin). + +Delegates to VNVAutomationController for the actual workflow. +""" + import logging -import os -from typing import Optional logger = logging.getLogger(__name__) @@ -12,156 +12,28 @@ class VNVAutomationMixin: """Mixin providing VNV automation methods for InstallModlistScreen.""" def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool: - """Check if VNV automation should run and execute if applicable in background thread - - Args: - modlist_name: Name of the installed modlist - install_dir: Installation directory path + """Check if VNV automation should run and start it if applicable. Returns: True if VNV automation is starting (success dialog should be deferred) False if no VNV automation needed (show success dialog immediately) """ - try: - from jackify.backend.services.vnv_integration_helper import should_offer_vnv_automation - from jackify.backend.handlers.path_handler import PathHandler - from jackify.backend.services.vnv_post_install_service import VNVPostInstallService - from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + from ..services.vnv_automation_controller import VNVAutomationController - # Get paths first (needed for VNV detection) - install_path = Path(install_dir) - - # Quick check before importing more (pass install location for ModOrganizer.ini check) - if not should_offer_vnv_automation(modlist_name, install_path): - return False - - game_paths = PathHandler().find_vanilla_game_paths() - game_root = game_paths.get('Fallout New Vegas') - - if not game_root: - logger.debug("DEBUG: VNV automation skipped - FNV game root not found") - return False - - # Initialize service to check completion status - vnv_service = VNVPostInstallService( - modlist_install_location=install_path, - game_root=game_root, - ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path() - ) - - # Check what's already done - completed = vnv_service.check_already_completed() - # Only skip if ALL three steps are completed - if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed']: - logger.info("VNV automation steps already completed") - return False - - # Get automation description for confirmation - description = vnv_service.get_automation_description() - - # Show confirmation dialog ON MAIN THREAD (not in worker thread!) - from ..services.message_service import MessageService - from PySide6.QtWidgets import QMessageBox - reply = MessageService.question( - self, - "VNV Post-Install Automation", - description, - critical=False, - safety_level="medium" - ) - - if reply != QMessageBox.Yes: - logger.info("User declined VNV automation") - return False - - # Enable post-install progress tracking for VNV automation - self._begin_post_install_feedback() - - # User confirmed - start automation in background thread - # Note: manual_file_callback is not passed because Qt GUI operations - # cannot be called from a background thread. If downloads fail, - # the service will return instructions for manual download instead. - self._run_vnv_automation_threaded( - modlist_name, - install_path, - game_root - ) - - return True # VNV automation is running, defer success dialog - - except Exception as e: - logger.debug(f"ERROR: Failed to start VNV automation: {e}") - import traceback - logger.debug(f"Traceback: {traceback.format_exc()}") - return False # Error - show success dialog anyway - - def _run_vnv_automation_threaded(self, modlist_name, install_path, game_root): - """Run VNV automation in a background thread with progress updates - - Note: User confirmation should already be obtained before calling this method. - Manual file selection is not supported from background threads - if downloads - fail, the service will return instructions for manual download. - """ - from PySide6.QtCore import QThread, Signal - from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable - from jackify.backend.services.automated_prefix_service import AutomatedPrefixService - - class VNVAutomationWorker(QThread): - progress_update = Signal(str) - completed = Signal(bool, str) # (success, error_message) - - def __init__(self, modlist_name, install_path, game_root, ttw_installer_path): - super().__init__() - self.modlist_name = modlist_name - self.install_path = install_path - self.game_root = game_root - self.ttw_installer_path = ttw_installer_path - - def run(self): - try: - # User already confirmed, pass lambda that always returns True - # manual_file_callback is None - downloads that fail will return - # instructions for manual download instead of showing Qt dialogs - automation_ran, error = run_vnv_automation_if_applicable( - modlist_name=self.modlist_name, - modlist_install_location=self.install_path, - game_root=self.game_root, - ttw_installer_path=self.ttw_installer_path, - progress_callback=self.progress_update.emit, - manual_file_callback=None, - confirmation_callback=lambda desc: True # Already confirmed on main thread - ) - self.completed.emit(error is None, error or "") - except Exception as e: - import traceback - self.completed.emit(False, f"Exception: {str(e)}\n{traceback.format_exc()}") - - # Create and start worker - self.vnv_worker = VNVAutomationWorker( - modlist_name, - install_path, - game_root, - AutomatedPrefixService.get_ttw_installer_path() + self._vnv_controller = VNVAutomationController() + return self._vnv_controller.attempt( + parent=self, + modlist_name=modlist_name, + install_dir=install_dir, + on_progress=self._safe_append_text, + on_complete=self._on_vnv_complete, + begin_feedback=self._begin_post_install_feedback, + handle_feedback=self._handle_post_install_progress, ) - # Connect signals - self.vnv_worker.progress_update.connect(self._on_vnv_progress) - self.vnv_worker.completed.connect(self._on_vnv_complete) - self.vnv_worker.finished.connect(self.vnv_worker.deleteLater) - - # Start worker - self.vnv_worker.start() - - def _on_vnv_progress(self, message: str): - """Handle VNV automation progress updates""" - self._safe_append_text(message) - # Also update progress indicator, Activity window, and Details window - self._handle_post_install_progress(message) - def _on_vnv_complete(self, success: bool, error: str): - """Handle VNV automation completion and show deferred success dialog""" - # End post-install feedback now that VNV automation is complete - self._end_post_install_feedback(True) + """Handle VNV automation completion and show deferred success dialog.""" + self._end_post_install_feedback(not bool(error)) if not success and error: from ..services.message_service import MessageService @@ -175,32 +47,26 @@ class VNVAutomationMixin: elif success: self._safe_append_text("VNV post-install automation completed successfully") - # Show the deferred success dialog now that VNV automation is complete if hasattr(self, '_pending_success_dialog_params'): params = self._pending_success_dialog_params - del self._pending_success_dialog_params # Clean up + del self._pending_success_dialog_params - # Clear Activity window before showing success dialog self.file_progress_list.clear() - # Show success dialog from ..dialogs import SuccessDialog success_dialog = SuccessDialog( modlist_name=params['modlist_name'], workflow_type="install", time_taken=params['time_taken'], game_name=params['game_name'], - parent=self + parent=self, ) success_dialog.show() - # Show ENB Proton dialog if ENB was detected if params.get('enb_detected'): try: from ..dialogs.enb_proton_dialog import ENBProtonDialog enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self) - enb_dialog.exec() # Modal dialog - blocks until user clicks OK + enb_dialog.exec() except Exception as e: - # Non-blocking: if dialog fails, just log and continue - logger.warning(f"Failed to show ENB dialog: {e}") - + logger.warning("Failed to show ENB dialog: %s", e) diff --git a/jackify/frontends/gui/screens/install_modlist_workflow.py b/jackify/frontends/gui/screens/install_modlist_workflow.py index 74e80a5..7f540f3 100644 --- a/jackify/frontends/gui/screens/install_modlist_workflow.py +++ b/jackify/frontends/gui/screens/install_modlist_workflow.py @@ -1,359 +1,367 @@ """Installation workflow methods for InstallModlistScreen (Mixin).""" -from pathlib import Path -from PySide6.QtCore import QTimer from PySide6.QtWidgets import QMessageBox import logging import os -import re +import shutil import time -from .install_modlist_installer_thread import InstallerThread +from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog from .install_modlist_output_mixin import InstallModlistOutputMixin -from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access -from jackify.shared.errors import install_dir_create_failed +from .install_modlist_workflow_execution import InstallWorkflowExecutionMixin logger = logging.getLogger(__name__) -class InstallWorkflowMixin(InstallModlistOutputMixin): +class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMixin): """Mixin providing installation workflow methods for InstallModlistScreen.""" - def validate_and_start_install(self): - import time - self._install_workflow_start_time = time.time() - logger.debug('DEBUG: validate_and_start_install called') + @staticmethod + def _normalize_version_token(value: str | None) -> str | None: + """Return a normalized version token for lightweight equality checks.""" + if value is None: + return None + token = str(value).strip() + if not token: + return None + token = token.lstrip("vV") + return token.lower() - # Immediately show "Initialising" status to provide feedback - self.progress_indicator.set_status("Initialising...", 0) - from PySide6.QtWidgets import QApplication - QApplication.processEvents() # Force UI update + @staticmethod + def _normalize_modlist_name(value: str | None) -> str: + return " ".join((value or "").strip().lower().split()) - # Reload config to pick up any settings changes made in Settings dialog - self.config_handler.reload_config() + def _get_requested_modlist_version(self, install_mode: str) -> str | None: + """Return selected modlist version from gallery metadata when available.""" + if install_mode != "online": + return None + info = getattr(self, "selected_modlist_info", None) or {} + return self._normalize_version_token(info.get("version")) - # Check protontricks before proceeding - if not self._check_protontricks(): - self.progress_indicator.reset() + def _evaluate_update_candidate( + self, + modlist_name: str, + install_dir: str, + install_mode: str, + existing_appid: str | None, + ) -> tuple[bool, dict]: + """ + Decide whether update-mode prompt should be shown. + + Policy: + - Require existing shortcut AppID and jackify_meta.json. + - Require modlist identity match (requested name == installed meta name). + - Version relation is informational: + - `different` when both requested/installed versions are available and differ. + - `same` when both are available and equal. + - `unknown` when either side is missing. + """ + from jackify.backend.utils.modlist_meta import read_modlist_meta + + result = { + "eligible": False, + "reason": "unknown", + "requested_version": None, + "installed_version": None, + "version_relation": "unknown", + "installed_name": None, + } + if not existing_appid: + result["reason"] = "missing_shortcut_appid" + return False, result + + meta = read_modlist_meta(install_dir) + if not meta: + result["reason"] = "missing_meta" + return False, result + + installed_name = (meta.get("modlist_name") or "").strip() + result["installed_name"] = installed_name + if self._normalize_modlist_name(installed_name) != self._normalize_modlist_name(modlist_name): + result["reason"] = "modlist_name_mismatch" + return False, result + + requested_version = self._get_requested_modlist_version(install_mode) + installed_version = self._normalize_version_token(meta.get("modlist_version")) + result["requested_version"] = requested_version + result["installed_version"] = installed_version + if requested_version and installed_version: + result["version_relation"] = "same" if requested_version == installed_version else "different" + + result["eligible"] = True + result["reason"] = "eligible" + return True, result + + def _resolve_modorganizer_ini_path(self, install_dir: str) -> str | None: + """Return ModOrganizer.ini path for standard/special layouts.""" + candidates = [ + os.path.join(install_dir, "ModOrganizer.ini"), + os.path.join(install_dir, "files", "ModOrganizer.ini"), + ] + for candidate in candidates: + if os.path.isfile(candidate): + return candidate + return None + + def _capture_mo2_path_state(self, ini_path: str) -> dict[str, str]: + """Capture path-critical keys from ModOrganizer.ini for update comparison.""" + state: dict[str, str] = {} + section = "root" + try: + with open(ini_path, "r", encoding="utf-8", errors="ignore") as f: + for raw_line in f: + line = raw_line.strip() + if not line or line.startswith(("#", ";")): + continue + if line.startswith("[") and line.endswith("]"): + section = line[1:-1].strip() or "root" + continue + if "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + key_lower = key.lower() + if ( + key_lower in {"gamepath", "download_directory"} + or key_lower.startswith("binary") + or key_lower.startswith("workingdirectory") + ): + state[f"{section}.{key}"] = value + except Exception as e: + logger.warning("Failed to capture MO2 path state from %s: %s", ini_path, e) + return state + + def _create_update_ini_backup(self, ini_path: str, label: str) -> str | None: + """Create timestamped backup of ModOrganizer.ini for update traceability.""" + try: + timestamp = time.strftime("%Y%m%d_%H%M%S") + backup_path = f"{ini_path}.{label}_{timestamp}.bak" + shutil.copy2(ini_path, backup_path) + return backup_path + except Exception as e: + logger.warning("Failed to create %s backup for %s: %s", label, ini_path, e) + return None + + def _record_pre_update_ini_snapshot(self, install_dir: str) -> None: + """Capture pre-engine MO2 ini snapshot/backup for update-mode comparison.""" + ini_path = self._resolve_modorganizer_ini_path(install_dir) + if not ini_path: + self._update_pre_engine_ini_path = None + self._update_pre_engine_ini_state = {} + logger.warning("Update mode: ModOrganizer.ini not found before engine phase") return - # Disable all controls during installation (except Cancel) - self._disable_controls_during_operation() - - try: - tab_index = self.source_tabs.currentIndex() - install_mode = 'online' - if tab_index == 1: # .wabbajack File tab - modlist = self.file_edit.text().strip() - if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'): - self._abort_with_message( - "warning", - "Invalid Modlist", - "Please select a valid .wabbajack file." - ) - return - install_mode = 'file' - else: - # For online modlists, ALWAYS use machine_url from selected_modlist_info - # Button text is now the display name (title), NOT the machine URL - if not hasattr(self, 'selected_modlist_info') or not self.selected_modlist_info: - self._abort_with_message( - "warning", - "Invalid Modlist", - "Modlist information is missing. Please select the modlist again from the gallery." - ) - return - - machine_url = self.selected_modlist_info.get('machine_url') - if not machine_url: - self._abort_with_message( - "warning", - "Invalid Modlist", - "Modlist information is incomplete. Please select the modlist again from the gallery." - ) - return - - # CRITICAL: Use machine_url, NOT button text - modlist = machine_url - install_dir = self.install_dir_edit.text().strip() - downloads_dir = self.downloads_dir_edit.text().strip() - - # Get authentication token (OAuth or API key) with automatic refresh - api_key, oauth_info = self.auth_service.get_auth_for_engine() - if not api_key: - self._abort_with_message( - "warning", - "Authorisation Required", - "Please authorise with Nexus Mods before installing modlists.\n\n" - "Click the 'Authorise' button above to log in with OAuth,\n" - "or configure an API key in Settings.", - safety_level="medium" - ) - return - - # Log authentication status at install start (Issue #111 diagnostics) - auth_method = self.auth_service.get_auth_method() - logger.info("=" * 60) - logger.info("Authentication Status at Install Start") - logger.info(f"Method: {auth_method or 'UNKNOWN'}") - logger.info(f"Token length: {len(api_key)} chars") - if len(api_key) >= 8: - logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}") - - if auth_method == 'oauth': - token_handler = self.auth_service.token_handler - token_info = token_handler.get_token_info() - if 'expires_in_minutes' in token_info: - logger.info(f"OAuth expires in: {token_info['expires_in_minutes']:.1f} minutes") - if token_info.get('refresh_token_likely_expired'): - logger.warning(f"OAuth refresh token age: {token_info['refresh_token_age_days']:.1f} days (may need re-auth)") - logger.info("=" * 60) - - modlist_name = self.modlist_name_edit.text().strip() - missing_fields = [] - if not modlist_name: - missing_fields.append("Modlist Name") - if not install_dir: - missing_fields.append("Install Directory") - if not downloads_dir: - missing_fields.append("Downloads Directory") - if missing_fields: - self._abort_with_message( - "warning", - "Missing Required Fields", - "Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields) - ) - return - from jackify.backend.handlers.validation_handler import ValidationHandler - validation_handler = ValidationHandler() - is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir)) - if not is_safe: - from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog - dlg = WarningDialog(reason, parent=self) - result = dlg.exec() - if not result or not dlg.confirmed: - self._abort_install_validation() - return - if not os.path.isdir(install_dir): - from ..services.message_service import MessageService - create = MessageService.question(self, "Create Directory?", - f"The install directory does not exist:\n{install_dir}\n\nWould you like to create it?", - critical=False # Non-critical, won't steal focus - ) - if create == QMessageBox.Yes: - try: - os.makedirs(install_dir, exist_ok=True) - except Exception as e: - MessageService.show_error(self, install_dir_create_failed(install_dir, str(e))) - self._abort_install_validation() - return - else: - self._abort_install_validation() - return - if not os.path.isdir(downloads_dir): - from ..services.message_service import MessageService - create = MessageService.question(self, "Create Directory?", - f"The downloads directory does not exist:\n{downloads_dir}\n\nWould you like to create it?", - critical=False # Non-critical, won't steal focus - ) - if create == QMessageBox.Yes: - try: - os.makedirs(downloads_dir, exist_ok=True) - except Exception as e: - MessageService.show_error(self, install_dir_create_failed(downloads_dir, str(e))) - self._abort_install_validation() - return - else: - self._abort_install_validation() - return - - # Handle resolution saving - resolution = self.resolution_combo.currentText() - if resolution and resolution != "Leave unchanged": - success = self.resolution_service.save_resolution(resolution) - if success: - logger.debug(f"DEBUG: Resolution saved successfully: {resolution}") - else: - logger.debug("DEBUG: Failed to save resolution") - else: - # Clear saved resolution if "Leave unchanged" is selected - if self.resolution_service.has_saved_resolution(): - self.resolution_service.clear_saved_resolution() - logger.debug("DEBUG: Saved resolution cleared") - - ensure_flatpak_steam_filesystem_access(Path(install_dir)) - - # Handle parent directory saving - self._save_parent_directories(install_dir, downloads_dir) - - # Detect game type and check support - game_type = None - game_name = None - - if install_mode == 'file': - # Parse .wabbajack file to get game type - wabbajack_path = Path(modlist) - result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path) - if result: - if isinstance(result, tuple): - game_type, raw_game_type = result - # Get display name for the game - display_names = { - 'skyrim': 'Skyrim', - 'fallout4': 'Fallout 4', - 'falloutnv': 'Fallout New Vegas', - 'oblivion': 'Oblivion', - 'starfield': 'Starfield', - 'oblivion_remastered': 'Oblivion Remastered', - 'enderal': 'Enderal' - } - if game_type == 'unknown' and raw_game_type: - game_name = raw_game_type - else: - game_name = display_names.get(game_type, game_type) - else: - game_type = result - display_names = { - 'skyrim': 'Skyrim', - 'fallout4': 'Fallout 4', - 'falloutnv': 'Fallout New Vegas', - 'oblivion': 'Oblivion', - 'starfield': 'Starfield', - 'oblivion_remastered': 'Oblivion Remastered', - 'enderal': 'Enderal' - } - game_name = display_names.get(game_type, game_type) - else: - # For online modlists, try to get game type from selected modlist - if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: - game_name = self.selected_modlist_info.get('game', '') - logger.debug(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'") - - # Map game name to game type - game_mapping = { - 'skyrim special edition': 'skyrim', - 'skyrim': 'skyrim', - 'fallout 4': 'fallout4', - 'fallout new vegas': 'falloutnv', - 'oblivion': 'oblivion', - 'starfield': 'starfield', - 'oblivion_remastered': 'oblivion_remastered', - 'enderal': 'enderal', - 'enderal special edition': 'enderal' - } - game_type = game_mapping.get(game_name.lower()) - logger.debug(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'") - if not game_type: - game_type = 'unknown' - logger.debug(f"DEBUG: Game type not found in mapping, setting to 'unknown'") - else: - logger.debug(f"DEBUG: No selected_modlist_info found") - game_type = 'unknown' - - # Store game type and name for later use - self._current_game_type = game_type - self._current_game_name = game_name - - # Check if game is supported - logger.debug(f"DEBUG: Checking if game_type '{game_type}' is supported") - logger.debug(f"DEBUG: game_type='{game_type}', game_name='{game_name}'") - is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False - logger.debug(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}") - - 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 - - self.console.clear() - self.process_monitor.clear() - - # R&D: Reset progress indicator for new installation - self.progress_indicator.reset() - self.progress_state_manager.reset() - self.file_progress_list.clear() - self.file_progress_list.start_cpu_tracking() # Start tracking CPU during installation - self._premium_notice_shown = False - self._stalled_download_start_time = None - self._stalled_download_notified = False - self._stalled_data_snapshot = 0 - self._token_error_notified = False # Reset token error notification - self._premium_failure_active = False - self._post_install_active = False - self._post_install_current_step = 0 - # Activity tab is always visible (tabs handle visibility automatically) - - # Update button states for installation - self.start_btn.setEnabled(False) - self.cancel_btn.setVisible(False) - self.cancel_install_btn.setVisible(True) - - # CRITICAL: Final safety check - ensure online modlists use machine_url - if install_mode == 'online': - if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: - expected_machine_url = self.selected_modlist_info.get('machine_url') - if expected_machine_url: - modlist = expected_machine_url # Force use machine_url - else: - self._abort_with_message( - "critical", - "Installation Error", - "Cannot determine modlist machine URL. Please select the modlist again." - ) - return - else: - self._abort_with_message( - "critical", - "Installation Error", - "Modlist information is missing. Please select the modlist again from the gallery." - ) - return - - 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: - logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}") - import traceback - logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}") - # Re-enable all controls after exception - self._enable_controls_after_operation() - self.cancel_btn.setVisible(True) - self.cancel_install_btn.setVisible(False) - logger.debug(f"DEBUG: Controls re-enabled in exception handler") - - def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None): - logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER') - - # Rotate log file at start of each workflow run (keep 5 backups) - from jackify.backend.handlers.logging_handler import LoggingHandler - log_handler = LoggingHandler() - log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5) - - # Clear console for fresh installation output - self.console.clear() - from jackify import __version__ as jackify_version - self._safe_append_text(f"Jackify v{jackify_version}") - self._safe_append_text("Starting modlist installation with custom progress handling...") - - # Update UI state for installation - self.start_btn.setEnabled(False) - self.cancel_btn.setVisible(False) - self.cancel_install_btn.setVisible(True) - - self.install_thread = InstallerThread( - modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode, - progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager - auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics - oauth_info=oauth_info # Pass OAuth state for auto-refresh + self._update_pre_engine_ini_path = ini_path + self._update_pre_engine_ini_state = self._capture_mo2_path_state(ini_path) + self._update_pre_engine_ini_backup = self._create_update_ini_backup(ini_path, "pre_update") + logger.info( + "Update mode: captured pre-engine MO2 state | ini=%s backup=%s keys=%d", + ini_path, + self._update_pre_engine_ini_backup, + len(self._update_pre_engine_ini_state), ) - self.install_thread.output_received.connect(self.on_installation_output) - self.install_thread.progress_received.connect(self.on_installation_progress) - self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update - self.install_thread.installation_finished.connect(self.on_installation_finished) - self.install_thread.premium_required_detected.connect(self.on_premium_required_detected) - # R&D: Pass progress state manager to thread - self.install_thread.progress_state_manager = self.progress_state_manager - self.install_thread.start() + + def _record_post_engine_ini_snapshot_and_diff(self, install_dir: str) -> None: + """Capture post-engine MO2 snapshot and log path-key drift vs pre-engine state.""" + ini_path = self._resolve_modorganizer_ini_path(install_dir) + if not ini_path: + logger.warning("Update mode: ModOrganizer.ini not found after engine phase") + return + + post_state = self._capture_mo2_path_state(ini_path) + post_backup = self._create_update_ini_backup(ini_path, "post_engine") + pre_state = getattr(self, "_update_pre_engine_ini_state", {}) or {} + + changed: list[str] = [] + for key in sorted(set(pre_state) | set(post_state)): + before = pre_state.get(key) + after = post_state.get(key) + if before != after: + changed.append(f"{key}: '{before}' -> '{after}'") + + self._update_ini_path_drift_detected = bool(changed) + self._update_post_engine_ini_state = post_state + self._update_post_engine_ini_path = ini_path + logger.info( + "Update mode: captured post-engine MO2 state | ini=%s backup=%s keys=%d changed=%d", + ini_path, + post_backup, + len(post_state), + len(changed), + ) + if changed: + logger.warning("Update mode: MO2 path-key changes detected after engine phase") + for change in changed: + logger.warning("Update mode INI diff | %s", change) + else: + logger.info("Update mode: no path-key changes detected in ModOrganizer.ini after engine phase") + + def _verify_update_ini_after_configuration(self, install_dir: str) -> None: + """Log-only verification of path-critical ModOrganizer.ini keys after update configuration.""" + summary = self._evaluate_update_ini_verification(install_dir) + if not summary.get("ini_found"): + logger.warning("Update mode verify: ModOrganizer.ini not found after configuration") + return + + logger.info( + "Update mode verify: MO2 ini post-config summary | ini=%s critical_keys=%d empty_critical=%d changed_vs_post_engine=%d changed_vs_pre_engine=%d", + summary["ini_path"], + summary["critical_key_count"], + summary["empty_critical_count"], + summary["changed_vs_post_engine_count"], + summary["changed_vs_pre_engine_count"], + ) + if summary["empty_critical_keys"]: + logger.warning("Update mode verify: empty critical MO2 keys detected") + for key in summary["empty_critical_keys"]: + logger.warning("Update mode verify | empty key: %s", key) + + def _evaluate_update_ini_verification(self, install_dir: str) -> dict: + """ + Evaluate post-config MO2 path-key integrity for update-mode installs. + + Returns a summary dictionary that can be consumed by logging or tests. + """ + ini_path = self._resolve_modorganizer_ini_path(install_dir) + if not ini_path: + return { + "ini_found": False, + "ini_path": None, + "critical_key_count": 0, + "empty_critical_count": 0, + "empty_critical_keys": [], + "changed_vs_post_engine_count": 0, + "changed_vs_pre_engine_count": 0, + "changed_vs_post_engine_keys": [], + "changed_vs_pre_engine_keys": [], + } + + final_state = self._capture_mo2_path_state(ini_path) + pre_state = getattr(self, "_update_pre_engine_ini_state", {}) or {} + post_engine_state = getattr(self, "_update_post_engine_ini_state", {}) or {} + + critical_items = { + k: v + for k, v in final_state.items() + if ( + k.lower().endswith(".gamepath") + or ".binary" in k.lower() + or ".workingdirectory" in k.lower() + or k.lower().endswith(".download_directory") + ) + } + empty_critical = [k for k, v in critical_items.items() if not (v or "").strip()] + + changed_vs_post_engine = [ + k + for k in sorted(set(post_engine_state) | set(final_state)) + if post_engine_state.get(k) != final_state.get(k) + ] + changed_vs_pre_engine = [ + k + for k in sorted(set(pre_state) | set(final_state)) + if pre_state.get(k) != final_state.get(k) + ] + return { + "ini_found": True, + "ini_path": ini_path, + "critical_key_count": len(critical_items), + "empty_critical_count": len(empty_critical), + "empty_critical_keys": empty_critical, + "changed_vs_post_engine_count": len(changed_vs_post_engine), + "changed_vs_pre_engine_count": len(changed_vs_pre_engine), + "changed_vs_post_engine_keys": changed_vs_post_engine, + "changed_vs_pre_engine_keys": changed_vs_pre_engine, + } + + def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None: + """Return existing Steam shortcut AppID for this install dir/name when present.""" + try: + from jackify.backend.handlers.shortcut_handler import ShortcutHandler + from jackify.backend.services.platform_detection_service import PlatformDetectionService + + platform_service = PlatformDetectionService.get_instance() + shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck, verbose=False) + + install_real = os.path.realpath(install_dir) + candidate_exes = [ + os.path.join(install_real, "ModOrganizer.exe"), + os.path.join(install_real, "files", "ModOrganizer.exe"), # Somnium layout + ] + + for exe_path in candidate_exes: + if not os.path.exists(exe_path): + continue + appid = shortcut_handler.get_appid_from_vdf(modlist_name, exe_path) + if appid: + return appid + + # Fallback: match by name + start dir from shortcuts.vdf even if exe moved + for shortcut in shortcut_handler.find_shortcuts_by_exe("ModOrganizer.exe"): + if ( + (shortcut.get("AppName", "").strip() == modlist_name.strip()) + and os.path.realpath(shortcut.get("StartDir", "")) == install_real + ): + raw_appid = shortcut.get("appid") + if raw_appid is not None: + return str(int(raw_appid) & 0xFFFFFFFF) + except Exception as e: + logger.warning("Update detection: failed shortcut lookup: %s", e) + return None + + def _prompt_update_or_new_install( + self, + modlist_name: str, + install_dir: str, + update_meta: dict | None = None, + ) -> str: + """Prompt user when update conditions are met. Returns: 'update'|'new'|'cancel'.""" + version_note = "" + if update_meta: + relation = update_meta.get("version_relation") + req = update_meta.get("requested_version") + inst = update_meta.get("installed_version") + if relation == "different": + version_note = ( + f"\n\nDetected version change: installed v{inst} -> selected v{req}." + ) + elif relation == "same" and inst: + version_note = ( + f"\n\nDetected same version (v{inst}). " + "Use the existing setup if you are repairing or reconfiguring this install." + ) + + body = ( + "Jackify detected an existing modlist installation in the selected directory.\n\n" + "Choose 'Use Existing Setup' to continue with the current install and Steam shortcut. " + "Choose 'Create New Shortcut' only if you want a separate Steam entry with a different name." + f"{version_note}" + ) + + action, new_name = prompt_existing_setup_dialog( + self, + window_title="Existing Modlist Setup Detected", + heading="Use Existing Setup or Create a New Shortcut", + body=body, + existing_name=modlist_name, + requested_name=modlist_name, + install_dir=install_dir, + field_label="New shortcut name", + reuse_label="Use Existing Setup", + new_label="Create New Shortcut", + cancel_label="Cancel", + ) + + if action == "reuse": + return "update" + if action == "new": + if not new_name: + MessageBox = QMessageBox # keep local usage explicit + MessageBox.warning(self, "Invalid Name", "Please enter a valid shortcut name.") + return "cancel" + if new_name == modlist_name: + QMessageBox.warning(self, "Same Name", "Please enter a different name to create a separate shortcut.") + return "cancel" + self.modlist_name_edit.setText(new_name) + return "new" + return "cancel" diff --git a/jackify/frontends/gui/screens/install_modlist_workflow_execution.py b/jackify/frontends/gui/screens/install_modlist_workflow_execution.py new file mode 100644 index 0000000..aab452b --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_workflow_execution.py @@ -0,0 +1,516 @@ +"""Execution workflow methods for InstallModlistScreen (Mixin).""" + +from pathlib import Path +from PySide6.QtWidgets import QMessageBox +import logging +import os + +from .install_modlist_installer_thread import InstallerThread +from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access +from jackify.shared.errors import install_dir_create_failed + +logger = logging.getLogger(__name__) + + +class InstallWorkflowExecutionMixin: + """Mixin containing install-run and manual-download dialog execution methods.""" + def validate_and_start_install(self): + import time + self._install_workflow_start_time = time.time() + logger.debug('DEBUG: validate_and_start_install called') + + # Immediately show "Initialising" status to provide feedback + self.progress_indicator.set_status("Initialising...", 0) + from PySide6.QtWidgets import QApplication + QApplication.processEvents() # Force UI update + + # Reload config to pick up any settings changes made in Settings dialog + self.config_handler.reload_config() + + # Check protontricks before proceeding + if not self._check_protontricks(): + self.progress_indicator.reset() + return + + # Disable all controls during installation (except Cancel) + self._disable_controls_during_operation() + + try: + tab_index = self.source_tabs.currentIndex() + install_mode = 'online' + if tab_index == 1: # .wabbajack File tab + modlist = self.file_edit.text().strip() + if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'): + self._abort_with_message( + "warning", + "Invalid Modlist", + "Please select a valid .wabbajack file." + ) + return + install_mode = 'file' + else: + # For online modlists, ALWAYS use machine_url from selected_modlist_info + # Button text is now the display name (title), NOT the machine URL + if not hasattr(self, 'selected_modlist_info') or not self.selected_modlist_info: + self._abort_with_message( + "warning", + "Invalid Modlist", + "Modlist information is missing. Please select the modlist again from the gallery." + ) + return + + machine_url = self.selected_modlist_info.get('machine_url') + if not machine_url: + self._abort_with_message( + "warning", + "Invalid Modlist", + "Modlist information is incomplete. Please select the modlist again from the gallery." + ) + return + + # CRITICAL: Use machine_url, NOT button text + modlist = machine_url + install_dir = self.install_dir_edit.text().strip() + downloads_dir = self.downloads_dir_edit.text().strip() + + # Get authentication token (OAuth or API key) with automatic refresh + api_key, oauth_info = self.auth_service.get_auth_for_engine() + if not api_key: + self._abort_with_message( + "warning", + "Authorisation Required", + "Please authorise with Nexus Mods before installing modlists.\n\n" + "Click the 'Authorise' button above to log in with OAuth,\n" + "or configure an API key in Settings.", + safety_level="medium" + ) + return + + # Log authentication status at install start (Issue #111 diagnostics) + auth_method = self.auth_service.get_auth_method() + logger.info("=" * 60) + logger.info("Authentication Status at Install Start") + logger.info(f"Method: {auth_method or 'UNKNOWN'}") + logger.info(f"Token length: {len(api_key)} chars") + if len(api_key) >= 8: + logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}") + + if auth_method == 'oauth': + token_handler = self.auth_service.token_handler + token_info = token_handler.get_token_info() + if 'expires_in_minutes' in token_info: + logger.info(f"OAuth expires in: {token_info['expires_in_minutes']:.1f} minutes") + if token_info.get('refresh_token_likely_expired'): + logger.warning(f"OAuth refresh token age: {token_info['refresh_token_age_days']:.1f} days (may need re-auth)") + logger.info("=" * 60) + + modlist_name = self.modlist_name_edit.text().strip() + missing_fields = [] + if not modlist_name: + missing_fields.append("Modlist Name") + if not install_dir: + missing_fields.append("Install Directory") + if not downloads_dir: + missing_fields.append("Downloads Directory") + if missing_fields: + self._abort_with_message( + "warning", + "Missing Required Fields", + "Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields) + ) + return + from jackify.backend.handlers.validation_handler import ValidationHandler + validation_handler = ValidationHandler() + is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir)) + if not is_safe: + from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog + dlg = WarningDialog(reason, parent=self) + result = dlg.exec() + if not result or not dlg.confirmed: + self._abort_install_validation() + return + if not os.path.isdir(install_dir): + from ..services.message_service import MessageService + create = MessageService.question(self, "Create Directory?", + f"The install directory does not exist:\n{install_dir}\n\nWould you like to create it?", + critical=False # Non-critical, won't steal focus + ) + if create == QMessageBox.Yes: + try: + os.makedirs(install_dir, exist_ok=True) + except Exception as e: + MessageService.show_error(self, install_dir_create_failed(install_dir, str(e))) + self._abort_install_validation() + return + else: + self._abort_install_validation() + return + if not os.path.isdir(downloads_dir): + from ..services.message_service import MessageService + create = MessageService.question(self, "Create Directory?", + f"The downloads directory does not exist:\n{downloads_dir}\n\nWould you like to create it?", + critical=False # Non-critical, won't steal focus + ) + if create == QMessageBox.Yes: + try: + os.makedirs(downloads_dir, exist_ok=True) + except Exception as e: + MessageService.show_error(self, install_dir_create_failed(downloads_dir, str(e))) + self._abort_install_validation() + return + else: + self._abort_install_validation() + return + + # Handle resolution saving + resolution = self.resolution_combo.currentText() + if resolution and resolution != "Leave unchanged": + success = self.resolution_service.save_resolution(resolution) + if success: + logger.debug(f"DEBUG: Resolution saved successfully: {resolution}") + else: + logger.debug("DEBUG: Failed to save resolution") + else: + # Clear saved resolution if "Leave unchanged" is selected + if self.resolution_service.has_saved_resolution(): + self.resolution_service.clear_saved_resolution() + logger.debug("DEBUG: Saved resolution cleared") + + ensure_flatpak_steam_filesystem_access(Path(install_dir)) + + # Handle parent directory saving + self._save_parent_directories(install_dir, downloads_dir) + + # Detect game type and check support + game_type = None + game_name = None + + if install_mode == 'file': + # Parse .wabbajack file to get game type + wabbajack_path = Path(modlist) + result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path) + if result: + if isinstance(result, tuple): + game_type, raw_game_type = result + # Get display name for the game + display_names = { + 'skyrim': 'Skyrim', + 'fallout4': 'Fallout 4', + 'falloutnv': 'Fallout New Vegas', + 'oblivion': 'Oblivion', + 'starfield': 'Starfield', + 'oblivion_remastered': 'Oblivion Remastered', + 'enderal': 'Enderal' + } + if game_type == 'unknown' and raw_game_type: + game_name = raw_game_type + else: + game_name = display_names.get(game_type, game_type) + else: + game_type = result + display_names = { + 'skyrim': 'Skyrim', + 'fallout4': 'Fallout 4', + 'falloutnv': 'Fallout New Vegas', + 'oblivion': 'Oblivion', + 'starfield': 'Starfield', + 'oblivion_remastered': 'Oblivion Remastered', + 'enderal': 'Enderal' + } + game_name = display_names.get(game_type, game_type) + else: + # For online modlists, try to get game type from selected modlist + if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: + game_name = self.selected_modlist_info.get('game', '') + logger.debug(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'") + + # Map game name to game type + game_mapping = { + 'skyrim special edition': 'skyrim', + 'skyrim': 'skyrim', + 'fallout 4': 'fallout4', + 'fallout new vegas': 'falloutnv', + 'oblivion': 'oblivion', + 'starfield': 'starfield', + 'oblivion_remastered': 'oblivion_remastered', + 'enderal': 'enderal', + 'enderal special edition': 'enderal' + } + game_type = game_mapping.get(game_name.lower()) + logger.debug(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'") + if not game_type: + game_type = 'unknown' + logger.debug(f"DEBUG: Game type not found in mapping, setting to 'unknown'") + else: + logger.debug(f"DEBUG: No selected_modlist_info found") + game_type = 'unknown' + + # Store game type and name for later use + self._current_game_type = game_type + self._current_game_name = game_name + + # Check if game is supported + logger.debug(f"DEBUG: Checking if game_type '{game_type}' is supported") + logger.debug(f"DEBUG: game_type='{game_type}', game_name='{game_name}'") + is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False + logger.debug(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}") + + 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 + + self.console.clear() + self.process_monitor.clear() + + # Collapse Show Details if it was left open by the previous run. + if self.show_details_checkbox.isChecked(): + self.show_details_checkbox.blockSignals(True) + self.show_details_checkbox.setChecked(False) + self.show_details_checkbox.blockSignals(False) + from PySide6.QtCore import Qt as _Qt + self._toggle_console_visibility(_Qt.Unchecked) + + # R&D: Reset progress indicator for new installation + self.progress_indicator.reset() + self.progress_state_manager.reset() + self.file_progress_list.clear() + self.file_progress_list.start_cpu_tracking() # Start tracking CPU during installation + self._is_update_install = False + self._existing_shortcut_appid = None + self._premium_notice_shown = False + self._stalled_download_start_time = None + self._stalled_download_notified = False + self._stalled_data_snapshot = 0 + self._token_error_notified = False # Reset token error notification + self._premium_failure_active = False + self._installation_cancelled = False + self._non_premium_gate_enabled = False + self._non_premium_info_acknowledged = False + self._pending_manual_download_events = None + self._post_install_active = False + self._post_install_current_step = 0 + # Activity tab is always visible (tabs handle visibility automatically) + + # Update button states for installation + self.start_btn.setEnabled(False) + self.cancel_btn.setVisible(False) + self.cancel_install_btn.setVisible(True) + + # Detect update-vs-new workflow before starting engine install. + from jackify.backend.utils.modlist_meta import JACKIFY_META_FILE + install_real = os.path.realpath(install_dir) + meta_exists = (Path(install_real) / JACKIFY_META_FILE).exists() + existing_appid = self._find_existing_shortcut_appid(modlist_name, install_real) + if meta_exists and existing_appid: + eligible, update_meta = self._evaluate_update_candidate( + modlist_name, + install_real, + install_mode, + existing_appid, + ) + if not eligible: + logger.info( + "Update mode not offered | reason=%s requested_name=%s installed_name=%s", + update_meta.get("reason"), + modlist_name, + update_meta.get("installed_name"), + ) + else: + logger.info( + "Update mode candidate | version_relation=%s requested_version=%s installed_version=%s", + update_meta.get("version_relation"), + update_meta.get("requested_version"), + update_meta.get("installed_version"), + ) + decision = self._prompt_update_or_new_install(modlist_name, install_real, update_meta) + if decision == "cancel": + self._abort_install_validation() + return + if decision == "new": + from ..services.message_service import MessageService + + MessageService.warning( + self, + "Shortcut Name Already Exists", + "A Steam shortcut with this name already points to this install directory.\n\n" + "For a new install, choose a different Modlist Name before starting.", + safety_level="medium", + ) + self._abort_install_validation() + return + # update + self._is_update_install = True + self._existing_shortcut_appid = existing_appid + self._safe_append_text( + f"Update mode selected. Reusing existing Steam shortcut AppID {existing_appid}." + ) + self._record_pre_update_ini_snapshot(install_real) + + # CRITICAL: Final safety check - ensure online modlists use machine_url + if install_mode == 'online': + if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: + expected_machine_url = self.selected_modlist_info.get('machine_url') + if expected_machine_url: + modlist = expected_machine_url # Force use machine_url + else: + self._abort_with_message( + "critical", + "Installation Error", + "Cannot determine modlist machine URL. Please select the modlist again." + ) + return + else: + self._abort_with_message( + "critical", + "Installation Error", + "Modlist information is missing. Please select the modlist again from the gallery." + ) + return + + 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: + logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}") + import traceback + logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}") + # Re-enable all controls after exception + self._enable_controls_after_operation() + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + logger.debug(f"DEBUG: Controls re-enabled in exception handler") + + def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None): + logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER') + + # Rotate log file at start of each workflow run (keep 5 backups) + from jackify.backend.handlers.logging_handler import LoggingHandler + log_handler = LoggingHandler() + log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5) + + # Clear console for fresh installation output + self.console.clear() + from jackify import __version__ as jackify_version + self._safe_append_text(f"Jackify v{jackify_version}") + self._safe_append_text("Starting modlist installation with custom progress handling...") + + # Update UI state for installation + self.start_btn.setEnabled(False) + self.cancel_btn.setVisible(False) + self.cancel_install_btn.setVisible(True) + + self._downloads_dir = downloads_dir + self.install_thread = InstallerThread( + modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode, + progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager + auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics + oauth_info=oauth_info # Pass OAuth state for auto-refresh + ) + self.install_thread.output_received.connect(self.on_installation_output) + self.install_thread.progress_received.connect(self.on_installation_progress) + self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update + self.install_thread.installation_finished.connect(self.on_installation_finished) + self.install_thread.premium_required_detected.connect(self.on_premium_required_detected) + self.install_thread.non_premium_detected.connect(self.on_non_premium_detected) + self.install_thread.manual_download_list_received.connect(self.on_manual_download_list_received) + # R&D: Pass progress state manager to thread + self.install_thread.progress_state_manager = self.progress_state_manager + self.install_thread.finished.connect(self.install_thread.deleteLater) + self.install_thread.start() + + def on_manual_download_list_received(self, events: list) -> None: + """Show the manual download dialog when the engine emits a batch of missing files.""" + try: + # Show non-premium info dialog synchronously before the file list. + # The engine is paused waiting for a continue signal at this point, + # so process_finished will not fire during exec() and close it prematurely. + if getattr(self, '_non_premium_gate_enabled', False) and not getattr(self, '_non_premium_info_acknowledged', False): + self._show_non_premium_info_dialog() + logger.info(f"[MDL-1005] Showing manual download dialog for batch | items={len(events)}") + self._show_manual_download_dialog(events) + except Exception as exc: + logger.error(f"Manual download dialog setup failed: {exc}", exc_info=True) + self._safe_append_text(f"\n[ERROR] Manual download dialog failed to open: {exc}\n") + + def _flush_pending_manual_download_events(self) -> None: + events = getattr(self, '_pending_manual_download_events', None) + if not events: + return + self._pending_manual_download_events = None + logger.info(f"[MDL-1007] Releasing queued manual download batch after acknowledgement | items={len(events)}") + self._show_manual_download_dialog(events) + + def _show_manual_download_dialog(self, events: list) -> None: + from pathlib import Path as _Path + from jackify.backend.handlers.config_handler import ConfigHandler + from jackify.backend.services.manual_download_manager import ManualDownloadManager + from jackify.frontends.gui.dialogs.manual_download_dialog import ManualDownloadDialog + + cfg_watch = ConfigHandler().get("manual_download_watch_directory", None) + watch_dir = None + if cfg_watch: + cfg_path = _Path(str(cfg_watch)).expanduser() + if cfg_path.is_dir(): + watch_dir = cfg_path + if watch_dir is None: + xdg_dl = Path(os.environ.get('XDG_DOWNLOAD_DIR', '')) if os.environ.get('XDG_DOWNLOAD_DIR') else None + watch_dir = xdg_dl if (xdg_dl and xdg_dl.is_dir()) else _Path.home() / 'Downloads' + dl_dir = _Path(self._downloads_dir) if hasattr(self, '_downloads_dir') else watch_dir + + loop_iteration = events[0].get('loop_iteration', 1) if events else 1 + count = len(events) + raw_limit = ConfigHandler().get('manual_download_concurrent_limit', 2) + try: + concurrent_limit = int(raw_limit) + except (TypeError, ValueError): + concurrent_limit = 2 + concurrent_limit = max(1, min(5, concurrent_limit)) + + 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" + ) + logger.info( + f"[MDL-1006] Manual download protocol initialized | count={count} " + f"loop_iteration={loop_iteration} watch_dir={watch_dir} downloads_dir={dl_dir}" + ) + + # New install run: start with a fresh manager/dialog to avoid stale statuses from prior runs. + if loop_iteration == 1: + if getattr(self, '_manual_dl_manager', None) is not None: + try: + self._manual_dl_manager.stop() + except Exception: + pass + self._manual_dl_manager = None + if getattr(self, '_manual_dl_dialog', None) is not None: + try: + self._manual_dl_dialog.close() + except Exception: + pass + self._manual_dl_dialog = None + + if not hasattr(self, '_manual_dl_manager') or self._manual_dl_manager is None: + self._manual_dl_manager = ManualDownloadManager( + modlist_download_dir=dl_dir, + watch_directory=watch_dir, + concurrent_limit=concurrent_limit, + on_send_continue=self.install_thread.send_continue, + ) + self._manual_dl_dialog = ManualDownloadDialog( + manager=self._manual_dl_manager, + modlist_name=self.modlist_name_edit.text().strip() if hasattr(self, 'modlist_name_edit') else '', + watch_directory=watch_dir, + concurrent_limit=concurrent_limit, + parent=self, + ) + + self._manual_dl_manager.load_items(events, loop_iteration) + self._manual_dl_dialog.load_items(self._manual_dl_manager.items) + + if not self._manual_dl_dialog.isVisible(): + self._manual_dl_dialog.show() diff --git a/jackify/frontends/gui/screens/install_ttw.py b/jackify/frontends/gui/screens/install_ttw.py index 91590eb..3f69e71 100644 --- a/jackify/frontends/gui/screens/install_ttw.py +++ b/jackify/frontends/gui/screens/install_ttw.py @@ -304,25 +304,28 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT """Clean up any running processes when the window closes or is cancelled""" logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes") - # Clean up InstallationThread if running - if hasattr(self, 'install_thread') and self.install_thread.isRunning(): + # 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) # Wait up to 3 seconds + self.install_thread.wait(3000) if self.install_thread.isRunning(): self.install_thread.terminate() - - # Clean up other threads - threads = [ - 'prefix_thread', 'config_thread', 'fetch_thread' - ] - for thread_name in threads: - if hasattr(self, thread_name): - thread = getattr(self, thread_name) - if thread and thread.isRunning(): - logger.debug(f"DEBUG: Terminating {thread_name}") - thread.terminate() - thread.wait(1000) # Wait up to 1 second + self.install_thread.wait(2000) + self.install_thread = None + + from PySide6.QtCore import QThread + for attr_name, value in list(vars(self).items()): + if attr_name == 'install_thread': + continue + 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) + except Exception: + pass def cancel_installation(self): """Cancel the currently running installation""" @@ -353,7 +356,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT """) # Cancel the installation thread if it exists - if hasattr(self, 'install_thread') and self.install_thread.isRunning(): + 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(): @@ -361,7 +364,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT self.install_thread.wait(1000) # Cancel the automated prefix thread if it exists - if hasattr(self, 'prefix_thread') and self.prefix_thread.isRunning(): + 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(): @@ -369,7 +372,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT self.prefix_thread.wait(1000) # Cancel the configuration thread if it exists - if hasattr(self, 'config_thread') and self.config_thread.isRunning(): + 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(): diff --git a/jackify/frontends/gui/screens/install_ttw_output.py b/jackify/frontends/gui/screens/install_ttw_output.py index b93bc4d..945504f 100644 --- a/jackify/frontends/gui/screens/install_ttw_output.py +++ b/jackify/frontends/gui/screens/install_ttw_output.py @@ -51,6 +51,9 @@ class TTWOutputMixin: is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx']) is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.'] + if is_error and 'cannot get directory path for location type' in lower_cleaned: + self._ttw_unclean_game_dir_detected = True + should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise) if should_show: diff --git a/jackify/frontends/gui/screens/install_ttw_thread.py b/jackify/frontends/gui/screens/install_ttw_thread.py index b2e7917..f184b63 100644 --- a/jackify/frontends/gui/screens/install_ttw_thread.py +++ b/jackify/frontends/gui/screens/install_ttw_thread.py @@ -143,7 +143,11 @@ class TTWInstallationThread(QThread): elif returncode == 0: self.installation_finished.emit(True, "TTW installation completed successfully!") else: - self.installation_finished.emit(False, f"TTW installation failed with exit code {returncode}") + self.installation_finished.emit( + False, + f"TTW installer exited unexpectedly (code {returncode}). " + "Review the recent console output for the failing step." + ) except Exception as e: import traceback diff --git a/jackify/frontends/gui/screens/install_ttw_workflow.py b/jackify/frontends/gui/screens/install_ttw_workflow.py index 45a0221..dace1cd 100644 --- a/jackify/frontends/gui/screens/install_ttw_workflow.py +++ b/jackify/frontends/gui/screens/install_ttw_workflow.py @@ -209,6 +209,14 @@ class TTWWorkflowMixin: font-size: 13px; """) self._safe_append_text(f"\nError: {message}") + if getattr(self, '_ttw_unclean_game_dir_detected', False): + self._safe_append_text( + "\nLikely cause: Your Fallout New Vegas game directory is not clean vanilla.\n" + "TTW requires an unmodified FNV installation to patch correctly.\n" + "If you have previously installed an FNV modlist that modifies the game directory,\n" + "verify or reinstall FNV via Steam to restore vanilla files, then try again." + ) + self._last_install_message = message self.process_finished(1, QProcess.CrashExit) def process_finished(self, exit_code, exit_status): @@ -247,9 +255,11 @@ class TTWWorkflowMixin: ) else: last_output = self.console.toPlainText() - if "cancelled by user" in last_output.lower(): + failure_msg = (getattr(self, '_last_install_message', '') or "").strip() + if "cancelled by user" in last_output.lower() or "cancelled by user" in failure_msg.lower(): MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") else: - MessageService.show_error(self, wabbajack_install_failed(f"Exit code {exit_code}. Check the console output for details.")) - self._safe_append_text(f"\nInstall failed (exit code {exit_code}).") + user_summary = failure_msg or "TTW installation failed. Review recent console output for the failing step." + MessageService.show_error(self, wabbajack_install_failed(user_summary)) + self._safe_append_text(f"\nInstall failed: {user_summary}") self.console.moveCursor(QTextCursor.End) diff --git a/jackify/frontends/gui/screens/screen_back_mixin.py b/jackify/frontends/gui/screens/screen_back_mixin.py index ce888c4..4fb0074 100644 --- a/jackify/frontends/gui/screens/screen_back_mixin.py +++ b/jackify/frontends/gui/screens/screen_back_mixin.py @@ -6,6 +6,7 @@ should use this mixin so the main window consistently collapses when leaving. """ from PySide6.QtCore import QSize, Qt +from PySide6.QtWidgets import QSizePolicy from ..utils import set_responsive_minimum @@ -48,3 +49,48 @@ class ScreenBackMixin: self.show_details_checkbox.blockSignals(False) if not is_steamdeck and hasattr(self, "_toggle_console_visibility"): self._toggle_console_visibility(Qt.Unchecked) + + def force_collapsed_details_state(self, resize_mode: str = "compact"): + """ + Normalize Show Details state when a screen is opened/reset. + + Some screens still manage console visibility locally instead of through a + single shared widget module. This helper forces the collapsed state in a + way that is safe across those implementations. + """ + try: + if hasattr(self, "show_details_checkbox"): + self.show_details_checkbox.blockSignals(True) + self.show_details_checkbox.setChecked(False) + self.show_details_checkbox.blockSignals(False) + except Exception: + pass + + try: + if hasattr(self, "console"): + self.console.setVisible(False) + self.console.setMinimumHeight(0) + self.console.setMaximumHeight(0) + self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + except Exception: + pass + + try: + if hasattr(self, "console_and_buttons_widget"): + self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.console_and_buttons_widget.setFixedHeight(50) + self.console_and_buttons_widget.updateGeometry() + except Exception: + pass + + try: + if hasattr(self, "main_overall_vbox") and hasattr(self, "console"): + self.main_overall_vbox.setStretchFactor(self.console, 0) + except Exception: + pass + + try: + if hasattr(self, "resize_request"): + self.resize_request.emit(resize_mode) + except Exception: + pass diff --git a/jackify/frontends/gui/screens/screen_focus_reclaim.py b/jackify/frontends/gui/screens/screen_focus_reclaim.py new file mode 100644 index 0000000..31ca540 --- /dev/null +++ b/jackify/frontends/gui/screens/screen_focus_reclaim.py @@ -0,0 +1,58 @@ +"""Shared mixin for reclaiming window focus after Steam restart.""" +import logging +from PySide6.QtCore import QTimer, Qt + +logger = logging.getLogger(__name__) + +STEAM_RESTART_SENTINEL = "[Jackify] Steam restart complete" + + +class FocusReclaimMixin: + """Mixin providing post-Steam-restart focus reclaim for any screen. + + Usage: inherit this mixin and call _start_focus_reclaim_retries() when + Steam restart is detected. Detection is typically done by checking + progress messages for STEAM_RESTART_SENTINEL. + """ + + def _start_focus_reclaim_retries(self): + try: + if hasattr(self, "_focus_reclaim_timer") and self._focus_reclaim_timer: + self._focus_reclaim_timer.stop() + self._focus_reclaim_timer.deleteLater() + except Exception: + pass + + self._focus_reclaim_attempt = 0 + self._focus_reclaim_max_attempts = 12 # ~24 seconds total + self._focus_reclaim_timer = QTimer(self) + self._focus_reclaim_timer.setInterval(2000) + self._focus_reclaim_timer.timeout.connect(self._focus_reclaim_tick) + self._focus_reclaim_timer.start() + self._focus_reclaim_tick() + + def _focus_reclaim_tick(self): + try: + win = self.window() + if win is None: + return + + self._focus_reclaim_attempt += 1 + win.raise_() + win.activateWindow() + win.setWindowState(win.windowState() & ~Qt.WindowMinimized | Qt.WindowActive) + + if win.isActiveWindow(): + logger.info("Foreground focus reclaimed after Steam restart") + self._focus_reclaim_timer.stop() + return + + if self._focus_reclaim_attempt >= self._focus_reclaim_max_attempts: + logger.warning("Foreground focus reclaim timed out after Steam restart") + self._focus_reclaim_timer.stop() + except Exception as e: + logger.debug(f"Focus reclaim tick failed: {e}") + try: + self._focus_reclaim_timer.stop() + except Exception: + pass diff --git a/jackify/frontends/gui/screens/wabbajack_installer.py b/jackify/frontends/gui/screens/wabbajack_installer.py index 33fe25b..a65f8bc 100644 --- a/jackify/frontends/gui/screens/wabbajack_installer.py +++ b/jackify/frontends/gui/screens/wabbajack_installer.py @@ -20,9 +20,12 @@ from PySide6.QtCore import Qt, QThread, Signal, QSize from PySide6.QtGui import QTextCursor from jackify.backend.models.configuration import SystemInfo +from jackify.backend.services.automated_prefix_service import AutomatedPrefixService from jackify.shared.errors import wabbajack_install_failed +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 ..widgets.file_progress_list import FileProgressList from ..widgets.progress_indicator import OverallProgressIndicator @@ -39,11 +42,12 @@ class WabbajackInstallerWorker(QThread): log_output = Signal(str) # Console log output installation_complete = Signal(bool, str, str, str, str) # Success, message, launch_options, app_id, time_taken - def __init__(self, install_folder: Path, shortcut_name: str = "Wabbajack", enable_gog: bool = True): + def __init__(self, install_folder: Path, shortcut_name: str = "Wabbajack", enable_gog: bool = True, existing_appid: int | None = None): super().__init__() self.install_folder = install_folder self.shortcut_name = shortcut_name self.enable_gog = enable_gog + self.existing_appid = existing_appid self.launch_options = "" # Store launch options for success message self.start_time = None # Track installation start time @@ -73,6 +77,7 @@ class WabbajackInstallerWorker(QThread): install_folder=self.install_folder, shortcut_name=self.shortcut_name, enable_gog=self.enable_gog, + existing_appid=self.existing_appid, progress_callback=progress_callback, log_callback=log_callback ) @@ -84,7 +89,7 @@ class WabbajackInstallerWorker(QThread): self.installation_complete.emit(False, error_msg or "Installation failed", "", "", "") -class WabbajackInstallerScreen(ScreenBackMixin, QWidget): +class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget): """Wabbajack installer GUI screen following standard Jackify layout""" resize_request = Signal(str) @@ -347,10 +352,17 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget): def _on_show_details_toggled(self, checked): """Handle Show details checkbox toggle""" - self.console.setVisible(checked) if checked: + self.console.setVisible(True) + self.console.setMinimumHeight(200) + self.console.setMaximumHeight(16777215) + self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.resize_request.emit("expand") else: + self.console.setVisible(False) + self.console.setMinimumHeight(0) + self.console.setMaximumHeight(0) + self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) self.resize_request.emit("compact") def _browse_folder(self): @@ -398,6 +410,51 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget): if confirm != QMessageBox.Yes: return + existing_appid = None + candidate_exe = self.install_folder / "Wabbajack.exe" + prefix_service = AutomatedPrefixService() + conflict_result = prefix_service.handle_existing_shortcut_conflict( + self.shortcut_name, + str(candidate_exe), + str(self.install_folder), + ) + if isinstance(conflict_result, list): + action, new_name = prompt_existing_setup_dialog( + self, + window_title="Existing Modlist Setup Detected", + heading="Use Existing Setup or Create a New Shortcut", + body=( + "Jackify found an existing Steam shortcut for this Wabbajack setup.\n\n" + "Choose 'Use Existing Setup' to reuse the current Steam shortcut, or enter a " + "different name to create a separate shortcut." + ), + existing_name=conflict_result[0].get("name", self.shortcut_name), + requested_name=self.shortcut_name, + install_dir=str(self.install_folder), + field_label="New shortcut name", + reuse_label="Use Existing Setup", + new_label="Create New Shortcut", + cancel_label="Cancel", + ) + if action == "reuse": + existing_appid = conflict_result[0].get("appid") + if not existing_appid: + MessageService.warning(self, "Existing Setup Not Found", "Jackify could not determine the Steam AppID for the existing shortcut.") + return + self._write_to_log_file(f"Reusing existing Steam shortcut '{self.shortcut_name}' with AppID {existing_appid}") + elif action == "new": + if not new_name: + MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") + return + if new_name == self.shortcut_name: + MessageService.warning(self, "Same Name", "Please enter a different name to create a separate shortcut.") + return + self.shortcut_name = new_name + self.shortcut_name_edit.setText(new_name) + else: + self._write_to_log_file("Shortcut creation cancelled by user") + return + # Clear displays self.console.clear() self.file_progress_list.clear() @@ -420,7 +477,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget): self.progress_indicator.set_status("Starting installation...", 0) # Start worker thread - self.worker = WabbajackInstallerWorker(self.install_folder, shortcut_name=self.shortcut_name, enable_gog=True) + self.worker = WabbajackInstallerWorker(self.install_folder, shortcut_name=self.shortcut_name, enable_gog=True, existing_appid=int(existing_appid) if existing_appid else None) self.worker.progress_update.connect(self._on_progress_update) self.worker.activity_update.connect(self._on_activity_update) self.worker.log_output.connect(self._on_log_output) @@ -428,8 +485,9 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget): self.worker.start() def _on_progress_update(self, message: str, percentage: int): - """Handle progress updates""" self.progress_indicator.set_status(message, percentage) + if STEAM_RESTART_SENTINEL in message: + self._start_focus_reclaim_retries() def _on_activity_update(self, label: str, current: int, total: int): """Handle activity tab updates""" @@ -569,6 +627,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget): def showEvent(self, event): """Called when widget becomes visible""" super().showEvent(event) + self.force_collapsed_details_state() try: main_window = self.window() if main_window: diff --git a/jackify/frontends/gui/services/vnv_automation_controller.py b/jackify/frontends/gui/services/vnv_automation_controller.py new file mode 100644 index 0000000..78e9d5f --- /dev/null +++ b/jackify/frontends/gui/services/vnv_automation_controller.py @@ -0,0 +1,402 @@ +""" +Shared VNV post-install automation controller for all GUI workflows. + +Handles VNV detection, user confirmation, premium/non-premium download paths, +worker thread management, and completion callbacks. +""" + +import logging +from pathlib import Path +from typing import Callable, Optional + +from PySide6.QtCore import QThread, Signal, Slot, QObject +from PySide6.QtWidgets import QMessageBox, QWidget + +logger = logging.getLogger(__name__) + + +class _VNVWorker(QThread): + """Background thread for VNV automation.""" + progress_update = Signal(str) + completed = Signal(bool, str) # (success, error_message) + + def __init__(self, modlist_name, install_path, game_root, ttw_installer_path): + super().__init__() + self._modlist_name = modlist_name + self._install_path = install_path + self._game_root = game_root + self._ttw_installer_path = ttw_installer_path + + def run(self): + try: + from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable + automation_ran, error = run_vnv_automation_if_applicable( + modlist_name=self._modlist_name, + modlist_install_location=self._install_path, + game_root=self._game_root, + ttw_installer_path=self._ttw_installer_path, + progress_callback=self.progress_update.emit, + manual_file_callback=None, + confirmation_callback=lambda desc: True, + ) + self.completed.emit(error is None, error or "") + except Exception as e: + import traceback + self.completed.emit(False, f"{e}\n{traceback.format_exc()}") + + +class VNVAutomationController(QObject): + """ + Single entry point for VNV post-install automation across all GUI workflows. + + Usage in any screen's on_configuration_complete: + + from ..services.vnv_automation_controller import VNVAutomationController + controller = VNVAutomationController() + if controller.attempt( + parent=self, + modlist_name=modlist_name, + install_dir=install_dir, + on_progress=self._safe_append_text, + on_complete=lambda success, error: self._on_vnv_done(success, error), + ): + # VNV is running, defer success dialog + return + # No VNV, show success dialog now + """ + + # Emitted from the watcher background thread; delivered on main thread + # via auto-queued connection because this object lives on the main thread. + _worker_start_requested = Signal() + + def __init__(self): + super().__init__() + self._worker: Optional[_VNVWorker] = None + self._manual_manager = None + self._manual_dialog = None + self._pending_worker_start: Optional[Callable] = None + self._on_progress_cb: Optional[Callable] = None + self._on_complete_cb: Optional[Callable] = None + self._handle_feedback_cb: Optional[Callable] = None + self._worker_start_requested.connect(self._dispatch_worker_start) + + def attempt( + self, + parent: QWidget, + modlist_name: str, + install_dir: str, + on_progress: Callable[[str], None], + on_complete: Callable[[bool, str], None], + begin_feedback: Optional[Callable[[], None]] = None, + handle_feedback: Optional[Callable[[str], None]] = None, + ) -> bool: + """Check for VNV eligibility and start automation if applicable. + + Args: + parent: Parent QWidget for dialogs + modlist_name: Name of the modlist + install_dir: Installation directory path + on_progress: Called with progress text messages + on_complete: Called with (success, error_message) when done + begin_feedback: Optional - start post-install progress UI + handle_feedback: Optional - update post-install progress UI + + Returns: + True if VNV automation is starting (caller should defer success dialog) + False if no VNV needed (caller should show success dialog immediately) + """ + try: + from jackify.backend.services.vnv_integration_helper import should_offer_vnv_automation + from jackify.backend.handlers.path_handler import PathHandler + from jackify.backend.services.vnv_post_install_service import VNVPostInstallService + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + + install_path = Path(install_dir) + + if not should_offer_vnv_automation(modlist_name, install_path): + return False + + game_paths = PathHandler().find_vanilla_game_paths() + game_root = game_paths.get('Fallout New Vegas') + if not game_root: + logger.debug("VNV automation skipped - FNV game root not found") + on_progress("VNV automation skipped: Fallout New Vegas path not found") + return False + + # Check completion status + vnv_service = VNVPostInstallService( + modlist_install_location=install_path, + game_root=game_root, + ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), + ) + completed = vnv_service.check_already_completed() + if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed']: + logger.info("VNV automation steps already completed") + return False + + # Confirmation dialog + from .message_service import MessageService + reply = MessageService.question( + parent, + "VNV Post-Install Automation", + vnv_service.get_automation_description(), + critical=False, + safety_level="medium", + ) + if reply != QMessageBox.Yes: + logger.info("User declined VNV automation") + on_progress("VNV automation skipped by user") + return False + + ttw_installer_path = AutomatedPrefixService.get_ttw_installer_path() + + # Non-premium path: route 4GB patcher through ManualDownloadManager + from jackify.backend.services.nexus_auth_service import NexusAuthService + from jackify.backend.services.nexus_premium_service import NexusPremiumService + + auth_svc = NexusAuthService() + token = auth_svc.get_auth_token() + is_premium = False + if token: + is_premium, _ = NexusPremiumService().check_premium_status( + token, is_oauth=(auth_svc.get_auth_method() == "oauth") + ) + + if not is_premium: + has_4gb_cache = vnv_service._find_cached_4gb_patcher() is not None + has_bsa_cache = ( + vnv_service._find_cached_bsa_mpi() is not None or + vnv_service._find_cached_bsa_package() is not None + ) + if has_4gb_cache and has_bsa_cache: + logger.debug("VNV non-premium: required VNV tools already cached, proceeding to worker") + else: + tool_events = vnv_service.get_manual_download_items(include_bsa=not has_bsa_cache) + logger.debug("VNV non-premium: tool_events=%d, cache_dir=%s", len(tool_events), vnv_service.cache_dir) + if tool_events: + if begin_feedback: + begin_feedback() + self._show_tool_download_dialog( + parent, tool_events, vnv_service.cache_dir, + modlist_name, install_path, game_root, ttw_installer_path, + on_progress, on_complete, handle_feedback, + ) + return True + else: + # 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") + try: + import subprocess + subprocess.Popen(['xdg-open', 'https://www.nexusmods.com/newvegas/mods/62552?tab=files']) + except Exception: + pass + from .message_service import MessageService + MessageService.information( + parent, + "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" + "1. Download the '4GB Patcher (Linux/Proton)' from:\n" + " nexusmods.com/newvegas/mods/62552\n\n" + "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.", + ) + return False + + # Premium or all tools already cached - start worker directly + if begin_feedback: + begin_feedback() + self._start_worker( + parent, modlist_name, install_path, game_root, + ttw_installer_path, on_progress, on_complete, handle_feedback, + ) + return True + + except Exception as e: + logger.error("Failed to start VNV automation: %s", e) + import traceback + logger.error("Traceback: %s", traceback.format_exc()) + return False + + def _dispatch_worker_start(self): + """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 + fn() + + def _show_tool_download_dialog( + self, parent, tool_events, cache_dir, + modlist_name, install_path, game_root, ttw_installer_path, + on_progress, on_complete, handle_feedback, + ): + """Show ManualDownloadDialog for VNV tools that need manual download.""" + from jackify.backend.services.manual_download_manager import ManualDownloadManager + from jackify.frontends.gui.dialogs.manual_download_dialog import ManualDownloadDialog + from jackify.backend.handlers.config_handler import ConfigHandler + + cfg_watch = ConfigHandler().get("manual_download_watch_directory", None) + watch_dir = None + if cfg_watch: + p = Path(str(cfg_watch)).expanduser() + if p.is_dir(): + watch_dir = p + if watch_dir is None: + import os + xdg = os.environ.get('XDG_DOWNLOAD_DIR', '') + xdg_path = Path(xdg).expanduser() if xdg else None + watch_dir = xdg_path if (xdg_path and xdg_path.is_dir()) else Path.home() / 'Downloads' + + 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 + # 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. + self._pending_worker_start = lambda: self._finish_manual_download_flow( + state, + parent, + modlist_name, + install_path, + game_root, + ttw_installer_path, + on_progress, + on_complete, + handle_feedback, + ) + self._worker_start_requested.emit() + + state = {"done": False} + + manager = ManualDownloadManager( + modlist_download_dir=cache_dir, + watch_directory=watch_dir, + concurrent_limit=2, + on_all_done=_on_all_done, + ) + self._manual_manager = manager + manager.load_items(tool_events, loop_iteration=1) + + dialog = ManualDownloadDialog( + manager=manager, + modlist_name="VNV Post-Install Tools", + watch_directory=watch_dir, + concurrent_limit=2, + parent=parent, + ) + self._manual_dialog = dialog + dialog.load_items(manager.items) + dialog.finished.connect(lambda _result: self._cancel_manual_download_flow(on_complete, state)) + dialog.show() + + def _cancel_manual_download_flow(self, on_complete, state: dict) -> None: + if state["done"]: + return + state["done"] = True + self._stop_manual_download_flow() + on_complete(False, "") + + def _finish_manual_download_flow( + self, + state: dict, + parent, + modlist_name, + install_path, + game_root, + ttw_installer_path, + on_progress, + on_complete, + handle_feedback, + ) -> None: + if state["done"]: + return + state["done"] = True + self._stop_manual_download_flow() + self._start_worker( + parent, + modlist_name, + install_path, + game_root, + ttw_installer_path, + on_progress, + on_complete, + handle_feedback, + ) + + def _stop_manual_download_flow(self) -> None: + dialog = self._manual_dialog + manager = self._manual_manager + self._manual_dialog = None + self._manual_manager = None + if dialog is not None: + try: + dialog.finished.disconnect() + except Exception: + pass + try: + dialog.close() + except Exception: + pass + if manager is not None: + try: + manager.stop() + except Exception: + pass + + def _start_worker( + self, parent, modlist_name, install_path, game_root, + ttw_installer_path, on_progress, on_complete, handle_feedback, + ): + """Create and start VNV worker thread. + + Signals are connected to @Slot methods on this QObject (main thread). + Because VNVAutomationController lives on the main thread, Qt automatically + uses queued connections for signals emitted from the worker thread, + guaranteeing that _on_worker_progress and _on_worker_done execute on + the main thread regardless of which thread the worker emits from. + """ + self._on_progress_cb = on_progress + self._on_complete_cb = on_complete + self._handle_feedback_cb = handle_feedback + + self._worker = _VNVWorker( + modlist_name, install_path, game_root, ttw_installer_path, + ) + self._worker.progress_update.connect(self._on_worker_progress) + self._worker.completed.connect(self._on_worker_done) + self._worker.finished.connect(self._worker.deleteLater) + self._worker.start() + + @Slot(str) + def _on_worker_progress(self, message: str): + if self._on_progress_cb: + self._on_progress_cb(message) + if self._handle_feedback_cb: + self._handle_feedback_cb(message) + + @Slot(bool, str) + def _on_worker_done(self, success: bool, error: str): + self._worker = None + cb = self._on_complete_cb + self._on_complete_cb = None + self._on_progress_cb = None + self._handle_feedback_cb = None + if cb: + cb(success, error) + + def cleanup(self): + """Stop worker if running. Call from screen cleanup/hideEvent.""" + self._on_complete_cb = None + self._on_progress_cb = None + self._handle_feedback_cb = None + 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 diff --git a/jackify/frontends/gui/widgets/file_progress_list.py b/jackify/frontends/gui/widgets/file_progress_list.py index bb31204..064ff58 100644 --- a/jackify/frontends/gui/widgets/file_progress_list.py +++ b/jackify/frontends/gui/widgets/file_progress_list.py @@ -342,7 +342,9 @@ class FileProgressList(QWidget): self._cpu_timer.stop() if self._cpu_worker and self._cpu_worker.isRunning(): self._cpu_worker.quit() - self._cpu_worker.wait(500) + if not self._cpu_worker.wait(500): + self._cpu_worker.terminate() + self._cpu_worker.wait(1000) self._cpu_worker = None def _start_cpu_worker(self): diff --git a/jackify/shared/appimage_utils.py b/jackify/shared/appimage_utils.py index e95c191..0596801 100644 --- a/jackify/shared/appimage_utils.py +++ b/jackify/shared/appimage_utils.py @@ -45,7 +45,7 @@ def get_appimage_path() -> Optional[Path]: if 'jackify' in path.name.lower(): return path else: - # Running from different AppImage (e.g., development in Cursor.AppImage) + # Running from a different AppImage context return None return None @@ -94,4 +94,4 @@ def get_appimage_info() -> dict: except (OSError, PermissionError): pass - return info \ No newline at end of file + return info diff --git a/jackify/shared/errors.py b/jackify/shared/errors.py index 36ae1ea..767d67f 100644 --- a/jackify/shared/errors.py +++ b/jackify/shared/errors.py @@ -75,12 +75,24 @@ def _looks_sensitive_key(key: str) -> bool: def _scrub_sensitive_text(text: str) -> str: """Best-effort redaction for key=value style sensitive fragments.""" scrubbed = text - patterns = [ + # Authorization header forms: "authorization: Bearer " + scrubbed = re.sub( + r"(?i)\bauthorization\b\s*[:=]\s*bearer\s+[A-Za-z0-9\-._~+/]+=*", + "authorization=[REDACTED]", + scrubbed, + ) + # Standalone bearer form: "Bearer " + scrubbed = re.sub( + r"(?i)\b(bearer)\s+[A-Za-z0-9\-._~+/]+=*", + r"\1=[REDACTED]", + scrubbed, + ) + # Generic sensitive key/value forms. + scrubbed = re.sub( r"(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|token|authorization|password|secret)\b\s*[:=]\s*([^\s,;]+)", - r"(?i)\b(bearer)\s+([A-Za-z0-9\-._~+/]+=*)", - ] - for pattern in patterns: - scrubbed = re.sub(pattern, r"\1=[REDACTED]", scrubbed) + r"\1=[REDACTED]", + scrubbed, + ) return scrubbed @@ -226,7 +238,24 @@ def modlist_not_found(path: str) -> ModlistError: ) -def configuration_failed(detail: str) -> ConfigError: +def game_not_found_for_modlist(game_name: str, detail: Optional[str] = None) -> InstallError: + game = (game_name or "Unknown game").strip() + return InstallError( + title="Required Game Not Found", + message=f"Jackify could not find the required base game: {game}", + suggestion="Install the base game in Steam, launch it once, then retry.", + solutions=[ + "Confirm the game is installed in Steam and fully updated.", + "Launch the vanilla game once from Steam to complete first-run setup.", + "If you have multiple Steam installs, ensure Jackify is pointed at the install that contains this game.", + "Restart Steam and retry the install workflow.", + f"If detection still fails, check Jackify logs ({_logs_dir_display()}) for game-detection details.", + ], + technical=format_technical_context(detail=detail, context={"required_game": game}), + ) + + +def configuration_failed(detail: str, context: Optional[dict] = None) -> ConfigError: return ConfigError( title="Post-Install Configuration Failed", message="Jackify could not complete the post-installation configuration for this modlist.", @@ -239,7 +268,7 @@ def configuration_failed(detail: str) -> ConfigError: "If the error mentions registry or prefix, ensure sufficient disk space.", f"If this still fails, check Jackify logs ({_logs_dir_display()}) and open a GitHub issue with modlist name.", ], - technical=format_technical_context(detail=detail), + technical=format_technical_context(detail=detail, context=context), ) @@ -261,7 +290,7 @@ def ttw_install_failed(detail: str) -> TTWError: ) -def wabbajack_install_failed(detail: str) -> InstallError: +def wabbajack_install_failed(detail: str, context: Optional[dict] = None) -> InstallError: return InstallError( title="Wabbajack Installation Failed", message="The modlist installation did not complete successfully.", @@ -275,7 +304,7 @@ def wabbajack_install_failed(detail: str) -> InstallError: "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.", ], - technical=format_technical_context(detail=detail), + technical=format_technical_context(detail=detail, context=context), ) @@ -324,6 +353,27 @@ def manual_steps_incomplete() -> ConfigError: ) +def cc_content_missing(filename: str = "") -> InstallError: + detail = f"Missing file: {filename}" if filename else "" + return InstallError( + title="Anniversary Edition Content Missing", + message=( + "One or more Skyrim Anniversary Edition Creation Club files were not found " + "in your game installation." + + (f" ({filename})" if filename else "") + ), + suggestion="Open Vanilla Skyrim and allow it to download the required Anniversary Edition content.", + solutions=[ + "Open Vanilla Skyrim SE/AE and let it run until all Creation Club content has downloaded.", + "From the Skyrim main menu, go into Creations and select 'Download All'.", + "If specific files are still missing, search for and download them individually from the Creations menu.", + "If problems persist, uninstall and reinstall Skyrim, then launch once to trigger the AE download.", + "Note: Skyrim AE via Steam Family Sharing does not transfer DLC content — you must own AE directly.", + ], + technical=format_technical_context(detail=detail) if detail else None, + ) + + def mo2_setup_failed(detail: str) -> InstallError: return InstallError( title="Mod Organizer 2 Setup Failed", diff --git a/jackify/shared/steam_utils.py b/jackify/shared/steam_utils.py index 3b45ba6..c88616b 100644 --- a/jackify/shared/steam_utils.py +++ b/jackify/shared/steam_utils.py @@ -5,12 +5,41 @@ Centralized Steam installation type detection to avoid redundant subprocess call """ import logging +import os +import re import subprocess import shutil -from typing import Tuple +from pathlib import Path +from typing import Dict, List, Optional, Tuple logger = logging.getLogger(__name__) +NATIVE_STEAM_ROOTS = [ + Path.home() / ".steam" / "steam", + Path.home() / ".local" / "share" / "Steam", + Path.home() / ".steam" / "root", +] + +FLATPAK_STEAM_ROOTS = [ + Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam", + Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / ".local" / "share" / "Steam", + Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "home" / ".local" / "share" / "Steam", +] + +STEAM_PREFERENCE_AUTO = "auto" +STEAM_PREFERENCE_NATIVE = "native" +STEAM_PREFERENCE_FLATPAK = "flatpak" + +# Common Jackify-supported game AppIDs used to infer which Steam install is actually in use. +_STEAM_USAGE_APPIDS = { + "489830", # Skyrim Special Edition + "377160", # Fallout 4 + "22380", # Fallout New Vegas + "22330", # Oblivion + "22370", # Fallout 3 + "1716740", # Starfield +} + def detect_steam_installation_types() -> Tuple[bool, bool]: """ @@ -21,14 +50,187 @@ def detect_steam_installation_types() -> Tuple[bool, bool]: Returns: Tuple[bool, bool]: (is_flatpak_steam, is_native_steam) """ - is_flatpak = _detect_flatpak_steam() - is_native = _detect_native_steam() + raw_flatpak = _detect_flatpak_steam() + raw_native = _detect_native_steam() - logger.info(f"Steam installation detection: Flatpak={is_flatpak}, Native={is_native}") + is_flatpak = raw_flatpak + is_native = raw_native + preferred_type, preferred_root = resolve_preferred_steam_installation() + + # Deterministic dual-install behavior: expose one active Steam type. + if raw_flatpak and raw_native: + if preferred_type == STEAM_PREFERENCE_FLATPAK: + is_flatpak, is_native = True, False + else: + is_flatpak, is_native = False, True + + logger.info( + "Steam installation detection: Flatpak=%s, Native=%s, Preferred=%s (%s), RawFlatpak=%s, RawNative=%s", + is_flatpak, + is_native, + preferred_type or "none", + preferred_root or "n/a", + raw_flatpak, + raw_native, + ) return is_flatpak, is_native +def get_steam_install_roots(install_type: Optional[str] = None) -> List[Path]: + """Return known Steam roots for a specific install type or both.""" + if install_type == STEAM_PREFERENCE_FLATPAK: + return list(FLATPAK_STEAM_ROOTS) + if install_type == STEAM_PREFERENCE_NATIVE: + return list(NATIVE_STEAM_ROOTS) + return list(NATIVE_STEAM_ROOTS) + list(FLATPAK_STEAM_ROOTS) + + +def is_flatpak_steam_root(path: Path) -> bool: + """Return True if a Steam root path belongs to Flatpak Steam.""" + path_str = str(path) + return ".var/app/com.valvesoftware.Steam" in path_str + + +def get_available_steam_roots() -> Dict[str, List[Path]]: + """Return discovered Steam roots grouped by install type.""" + roots = { + STEAM_PREFERENCE_NATIVE: [], + STEAM_PREFERENCE_FLATPAK: [], + } + for root in NATIVE_STEAM_ROOTS: + if root.exists(): + roots[STEAM_PREFERENCE_NATIVE].append(root) + for root in FLATPAK_STEAM_ROOTS: + if root.exists(): + roots[STEAM_PREFERENCE_FLATPAK].append(root) + return roots + + +def get_ordered_steam_roots(preference: str = STEAM_PREFERENCE_AUTO) -> List[Path]: + """ + Return Steam roots in deterministic priority order. + + If both native and flatpak are installed, preference controls order. + AUTO uses the most recently active install (loginusers.vdf timestamp/mtime). + """ + available = get_available_steam_roots() + native_roots = available[STEAM_PREFERENCE_NATIVE] + flatpak_roots = available[STEAM_PREFERENCE_FLATPAK] + + if preference not in { + STEAM_PREFERENCE_AUTO, + STEAM_PREFERENCE_NATIVE, + STEAM_PREFERENCE_FLATPAK, + }: + preference = STEAM_PREFERENCE_AUTO + + if preference == STEAM_PREFERENCE_NATIVE: + return native_roots + flatpak_roots + if preference == STEAM_PREFERENCE_FLATPAK: + return flatpak_roots + native_roots + + preferred_type, _ = resolve_preferred_steam_installation(STEAM_PREFERENCE_AUTO) + if preferred_type == STEAM_PREFERENCE_FLATPAK: + return flatpak_roots + native_roots + return native_roots + flatpak_roots + + +def resolve_preferred_steam_installation( + preference: str = STEAM_PREFERENCE_AUTO, +) -> Tuple[Optional[str], Optional[Path]]: + """ + Resolve the preferred Steam install type/root deterministically. + + Priority: + 1) Explicit preference (`native` or `flatpak`) if installed + 2) AUTO mode: whichever install has more relevant installed-game manifests + 3) AUTO tie-break: newest loginusers activity marker + 4) Deterministic fallback: native first, then flatpak + """ + available = get_available_steam_roots() + native_roots = available[STEAM_PREFERENCE_NATIVE] + flatpak_roots = available[STEAM_PREFERENCE_FLATPAK] + + if preference == STEAM_PREFERENCE_NATIVE and native_roots: + return STEAM_PREFERENCE_NATIVE, native_roots[0] + if preference == STEAM_PREFERENCE_FLATPAK and flatpak_roots: + return STEAM_PREFERENCE_FLATPAK, flatpak_roots[0] + + if native_roots and flatpak_roots: + native_game_score = _steam_root_game_presence_score(native_roots[0]) + flatpak_game_score = _steam_root_game_presence_score(flatpak_roots[0]) + if flatpak_game_score > native_game_score: + return STEAM_PREFERENCE_FLATPAK, flatpak_roots[0] + if native_game_score > flatpak_game_score: + return STEAM_PREFERENCE_NATIVE, native_roots[0] + + native_score = _steam_root_activity_score(native_roots[0]) + flatpak_score = _steam_root_activity_score(flatpak_roots[0]) + if flatpak_score > native_score: + return STEAM_PREFERENCE_FLATPAK, flatpak_roots[0] + return STEAM_PREFERENCE_NATIVE, native_roots[0] + + if native_roots: + return STEAM_PREFERENCE_NATIVE, native_roots[0] + if flatpak_roots: + return STEAM_PREFERENCE_FLATPAK, flatpak_roots[0] + return None, None + + +def _steam_root_activity_score(steam_root: Path) -> float: + """ + Return a comparable activity score for Steam root. + Uses loginusers.vdf mtime as a robust cross-layout signal. + """ + try: + loginusers = steam_root / "config" / "loginusers.vdf" + if loginusers.exists(): + return os.path.getmtime(loginusers) + except Exception as exc: + logger.debug("Could not read Steam activity marker for %s: %s", steam_root, exc) + return 0.0 + + +def _steam_root_game_presence_score(steam_root: Path) -> int: + """ + Score a Steam root by presence of relevant installed game appmanifests. + Higher score means that Steam install is more likely the one user is actively using. + """ + score = 0 + for library_root in _get_library_roots_for_steam_root(steam_root): + steamapps = library_root / "steamapps" + if not steamapps.is_dir(): + continue + for app_id in _STEAM_USAGE_APPIDS: + manifest = steamapps / f"appmanifest_{app_id}.acf" + if manifest.is_file(): + score += 1 + return score + + +def _get_library_roots_for_steam_root(steam_root: Path) -> List[Path]: + """ + Return Steam library roots for a given Steam root using libraryfolders.vdf. + Includes the primary Steam root as a fallback. + """ + roots: List[Path] = [steam_root] + vdf_path = steam_root / "config" / "libraryfolders.vdf" + if not vdf_path.is_file(): + return roots + + try: + text = vdf_path.read_text(encoding="utf-8", errors="ignore") + for match in re.finditer(r'"path"\s*"([^"]+)"', text): + raw_path = match.group(1).replace("\\\\", "\\") + lib_root = Path(raw_path).expanduser() + if lib_root not in roots: + roots.append(lib_root) + except Exception as exc: + logger.debug("Failed reading %s: %s", vdf_path, exc) + return roots + + def _detect_flatpak_steam() -> bool: """Detect if Steam is installed as a Flatpak.""" try: @@ -58,16 +260,8 @@ def _detect_flatpak_steam() -> bool: def _detect_native_steam() -> bool: """Detect if native Steam installation exists.""" try: - # Check for common Steam paths - import os - steam_paths = [ - os.path.expanduser("~/.steam/steam"), - os.path.expanduser("~/.local/share/Steam"), - os.path.expanduser("~/.steam/root") - ] - - for path in steam_paths: - if os.path.exists(path): + for path in NATIVE_STEAM_ROOTS: + if path.exists(): logger.debug(f"Native Steam detected at: {path}") return True diff --git a/requirements.txt b/requirements.txt index 588268a..ac154a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,4 +31,7 @@ packaging>=21.0 # Time handling (stdlib: time, datetime) # Collections (stdlib: collections, itertools, functools) # Configuration files (stdlib: configparser) -# Hashing (stdlib: hashlib) \ No newline at end of file +# Hashing (stdlib: hashlib) + +# Non-premium download support +watchdog>=3.0.0 \ No newline at end of file