diff --git a/CHANGELOG.md b/CHANGELOG.md index e969d71..ecdb267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Jackify Changelog +## v0.7.1.1 - Synthesis, Starfield, and Stability Fixes +**Release Date:** 2026-07-08 + +### Fixes +- Fixed vcrun2022/vcrun2012 native component installs leaving Wine's own runtime DLLs in effect instead of the real ones, causing DLL load failures in some situations. +- Fixed instances where Synthesis under Proton spawning huge numbers of dotnet processes and running the system out of memory during patcher compilation. +- Improved Synthesis' required NuGet security certificate installation. +- Fixed a related NuGet signature validation failure (NU3037/NU3028) affecting some patchers that depend on older signed packages. +- Fixed Starfield modlists failing to launch on first use and showing a MO2 warning about missing profile INI files. Added recognition for Oblivion Remastered. +- Fixed the resolution dropdown being duplicated across three separate screens, and added detection of the actual connected screen resolutions. +- Fixed an intermittent crash when closing the install/configure window while CPU usage tracking was still running in the background. +- Fixed the archive download progress total sometimes shrinking or climbing indefinitely during large downloads. +- Fixed some modlists showing a "Failed to start the helper application: nxmhandler.exe" error when closing MO2. + +--- + ## v0.7.1 - Remote Manifest System, Stability Fixes **Release Date:** 2026-07-02 diff --git a/jackify/__init__.py b/jackify/__init__.py index 3423db5..3f45289 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.7.1" +__version__ = "0.7.1.1" diff --git a/jackify/backend/data/dll_overrides.reg b/jackify/backend/data/dll_overrides.reg index 08ba173..194c4d9 100644 --- a/jackify/backend/data/dll_overrides.reg +++ b/jackify/backend/data/dll_overrides.reg @@ -64,18 +64,18 @@ Windows Registry Editor Version 5.00 "xactengine3_5"="native,builtin" "xactengine3_6"="native,builtin" "xactengine3_7"="native,builtin" -"atl110"="native,builtin" -"msvcp110"="native,builtin" -"msvcr110"="native,builtin" -"vcomp110"="native,builtin" -"concrt140"="native,builtin" -"msvcp140"="native,builtin" -"msvcp140_1"="native,builtin" -"msvcp140_2"="native,builtin" -"msvcp140_atomic_wait"="native,builtin" -"msvcp140_codecvt_ids"="native,builtin" -"vcamp140"="native,builtin" -"vccorlib140"="native,builtin" -"vcomp140"="native,builtin" -"vcruntime140"="native,builtin" -"vcruntime140_1"="native,builtin" +"*atl110"="native,builtin" +"*msvcp110"="native,builtin" +"*msvcr110"="native,builtin" +"*vcomp110"="native,builtin" +"*concrt140"="native,builtin" +"*msvcp140"="native,builtin" +"*msvcp140_1"="native,builtin" +"*msvcp140_2"="native,builtin" +"*msvcp140_atomic_wait"="native,builtin" +"*msvcp140_codecvt_ids"="native,builtin" +"*vcamp140"="native,builtin" +"*vccorlib140"="native,builtin" +"*vcomp140"="native,builtin" +"*vcruntime140"="native,builtin" +"*vcruntime140_1"="native,builtin" diff --git a/jackify/backend/handlers/filesystem_handler.py b/jackify/backend/handlers/filesystem_handler.py index a4f8561..cafc0db 100644 --- a/jackify/backend/handlers/filesystem_handler.py +++ b/jackify/backend/handlers/filesystem_handler.py @@ -19,11 +19,13 @@ from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET from .filesystem_handler_download import FilesystemDownloadMixin from .filesystem_handler_ownership import FilesystemOwnershipMixin from .filesystem_handler_steam import FilesystemSteamMixin +from .filesystem_handler_first_launch import FilesystemFirstLaunchMixin logger = logging.getLogger(__name__) - -class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, FilesystemSteamMixin): +class FileSystemHandler( + FilesystemDownloadMixin, FilesystemOwnershipMixin, FilesystemSteamMixin, FilesystemFirstLaunchMixin +): def __init__(self): self.logger = logging.getLogger(__name__) @@ -384,8 +386,6 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files logger.error(f"Failed to add backupPath entry to {modlist_ini}: {e}") return False # Backup succeeded, but adding entry failed - - def copy_file(self, src: Path, dst: Path, overwrite: bool = False) -> bool: """ Copy a file from source to destination. @@ -474,19 +474,20 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files self.logger.error(f"Error deleting directory: {e}") return False - def create_required_dirs(self, game_name: str, appid: str) -> bool: + def create_required_dirs(self, game_name: str, appid: str, modlist_dir: Optional[str] = None) -> bool: """ Create required directories for a game modlist - + This includes both Linux home directories and Wine prefix directories. Creating the Wine prefix Documents directories is critical for USVFS to work properly on first launch - USVFS needs the target directory to exist before it can virtualize profile INI files. - + Args: game_name: Name of the game (e.g., skyrimse, fallout4) appid: Steam AppID of the modlist - + modlist_dir: Modlist install directory, used to seed the MO2 profile's own + INI files for games whose plugin has no bundled default-ini fallback Returns: bool: True if directories were created successfully, False otherwise """ @@ -502,8 +503,10 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files "fallout4vr": "Fallout4VR", "falloutnv": "FalloutNV", "oblivion": "Oblivion", + "oblivion_remastered": "Oblivion Remastered", "enderal": "Enderal Special Edition", "enderalse": "Enderal Special Edition", + "starfield": "Starfield", } game_dirs = { @@ -585,6 +588,8 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files self._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name) elif game_name == "fallout4vr": self._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name) + elif game_name == "starfield": + self._seed_starfield_first_launch_files(prefix_user, docs_dir_name, modlist_dir) else: self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix directory creation") @@ -593,139 +598,3 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files self.logger.error(f"Error creating required directories: {e}") return False - def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: - """ - Pre-seed files in the Wine prefix that Skyrim SE/AE needs on first launch. - - Two files must exist before first launch to avoid USVFS and engine issues: - - 1. AppData/Local/Skyrim Special Edition/Plugins.txt - empty anchor file. - USVFS builds its VFS tree at MO2 startup. If this path does not exist, - USVFS logs the directory as missing and skips adding Plugins.txt to the - initial tree. It then tries to reroute the file dynamically, but a mutex - deadlock (thread never releases the write mutex on first launch) blocks - the reroute. The game falls through to the real filesystem, finds no - Plugins.txt, and loads only base-game ESPs - causing a null form crash - for any SKSE plugin that expects modlist ESPs (e.g. BladeAndBlunt.dll). - On second launch the directory exists, USVFS initialises correctly, no crash. - Pre-seeding an empty file gives USVFS its anchor; content is irrelevant - because USVFS reroutes reads to the active MO2 profile's plugins.txt anyway. - - 2. Documents/My Games/Skyrim Special Edition/SkyrimPrefs.ini - minimal stub. - The CC/AE download prompt is triggered by bDownloadCC=0 (or absent) in - SkyrimPrefs.ini. This check fires before PrivateProfileRedirector (PPR) - hooks the Windows INI API, so the game reads the real prefix path directly, - not the MO2 profile version. A minimal stub with bDownloadCC=1 suppresses - the prompt. PPR redirects all subsequent reads to the active profile once - it loads, so this stub is never read again after early engine init. - Only created if the file does not already exist. - """ - # Fix 1: empty Plugins.txt anchor for USVFS - appdata_sse = os.path.join(prefix_user, "AppData", "Local", "Skyrim Special Edition") - plugins_txt = os.path.join(appdata_sse, "Plugins.txt") - try: - os.makedirs(appdata_sse, exist_ok=True) - if not os.path.exists(plugins_txt): - open(plugins_txt, 'w').close() - self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") - else: - self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") - except Exception as e: - self.logger.warning(f"Could not create Plugins.txt anchor: {e}") - - # Fix 2: minimal SkyrimPrefs.ini at real Documents path to suppress AE popup - skyrimprefs_path = os.path.join( - prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini" - ) - try: - if not os.path.exists(skyrimprefs_path): - with open(skyrimprefs_path, 'w', encoding='utf-8') as f: - f.write("[General]\nbDownloadCC=1\n") - self.logger.info(f"Created SkyrimPrefs.ini stub to suppress AE popup: {skyrimprefs_path}") - else: - self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}") - except Exception as e: - self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}") - - def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: - """ - Pre-seed files in the Wine prefix that Fallout 4 needs on first launch. - - 1. AppData/Local/Fallout4/Plugins.txt - empty anchor file for USVFS. - Same mutex deadlock mechanism as Skyrim SE - confirmed to apply to FO4. - - INI stub for CC popup suppression is intentionally omitted until the correct - key name in Fallout4Prefs.ini is confirmed via testing. - """ - appdata_fo4 = os.path.join(prefix_user, "AppData", "Local", docs_dir_name) - plugins_txt = os.path.join(appdata_fo4, "Plugins.txt") - try: - os.makedirs(appdata_fo4, exist_ok=True) - if not os.path.exists(plugins_txt): - open(plugins_txt, 'w').close() - self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") - else: - self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") - except Exception as e: - self.logger.warning(f"Could not create Plugins.txt anchor: {e}") - - def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: - """ - Pre-seed files in the Wine prefix that Skyrim VR needs on first launch. - - 1. AppData/Local/Skyrim VR/Plugins.txt - empty anchor file for USVFS. - Same mutex deadlock mechanism as Skyrim SE applies to VR. - - 2. Documents/My Games/Skyrim VR/SkyrimPrefs.ini - minimal stub with two keys: - - bDownloadCC=1: suppresses the AE/CC download prompt (same engine behaviour - as Skyrim SE; fires before PPR hooks the INI API). - - bLoadVRPlayroom=0: prevents the game loading the Bethesda VR playroom - tutorial on first launch. Without this, SkyrimVR skips the main menu and - drops the user into the playroom, bypassing the modlist's startup sequence. - """ - appdata_vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name) - plugins_txt = os.path.join(appdata_vr, "Plugins.txt") - try: - os.makedirs(appdata_vr, exist_ok=True) - if not os.path.exists(plugins_txt): - open(plugins_txt, 'w').close() - self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") - else: - self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") - except Exception as e: - self.logger.warning(f"Could not create Plugins.txt anchor: {e}") - - skyrimprefs_path = os.path.join( - prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini" - ) - try: - if not os.path.exists(skyrimprefs_path): - with open(skyrimprefs_path, 'w', encoding='utf-8') as f: - f.write("[General]\nbDownloadCC=1\nbLoadVRPlayroom=0\n") - self.logger.info(f"Created SkyrimPrefs.ini stub for VR first-launch: {skyrimprefs_path}") - else: - self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}") - except Exception as e: - self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}") - - def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: - """ - Pre-seed files in the Wine prefix that Fallout 4 VR needs on first launch. - - 1. AppData/Local/Fallout4VR/Plugins.txt - empty anchor file for USVFS. - Same mutex deadlock mechanism as Skyrim SE and FO4 applies to VR. - - INI stub is intentionally omitted - the correct key name in Fallout4VRPrefs.ini - has not been confirmed via testing. - """ - appdata_fo4vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name) - plugins_txt = os.path.join(appdata_fo4vr, "Plugins.txt") - try: - os.makedirs(appdata_fo4vr, exist_ok=True) - if not os.path.exists(plugins_txt): - open(plugins_txt, 'w').close() - self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") - else: - self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") - except Exception as e: - self.logger.warning(f"Could not create Plugins.txt anchor: {e}") diff --git a/jackify/backend/handlers/filesystem_handler_first_launch.py b/jackify/backend/handlers/filesystem_handler_first_launch.py new file mode 100644 index 0000000..ccaeedf --- /dev/null +++ b/jackify/backend/handlers/filesystem_handler_first_launch.py @@ -0,0 +1,245 @@ +"""First-launch INI/anchor file seeding for Bethesda games (Mixin).""" + +import os +from typing import Optional + + +class FilesystemFirstLaunchMixin: + """Mixin providing per-game first-launch file seeding methods.""" + + def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: + """ + Pre-seed files in the Wine prefix that Skyrim SE/AE needs on first launch. + + Two files must exist before first launch to avoid USVFS and engine issues: + + 1. AppData/Local/Skyrim Special Edition/Plugins.txt - empty anchor file. + USVFS builds its VFS tree at MO2 startup. If this path does not exist, + USVFS logs the directory as missing and skips adding Plugins.txt to the + initial tree. It then tries to reroute the file dynamically, but a mutex + deadlock (thread never releases the write mutex on first launch) blocks + the reroute. The game falls through to the real filesystem, finds no + Plugins.txt, and loads only base-game ESPs - causing a null form crash + for any SKSE plugin that expects modlist ESPs (e.g. BladeAndBlunt.dll). + On second launch the directory exists, USVFS initialises correctly, no crash. + Pre-seeding an empty file gives USVFS its anchor; content is irrelevant + because USVFS reroutes reads to the active MO2 profile's plugins.txt anyway. + + 2. Documents/My Games/Skyrim Special Edition/SkyrimPrefs.ini - minimal stub. + The CC/AE download prompt is triggered by bDownloadCC=0 (or absent) in + SkyrimPrefs.ini. This check fires before PrivateProfileRedirector (PPR) + hooks the Windows INI API, so the game reads the real prefix path directly, + not the MO2 profile version. A minimal stub with bDownloadCC=1 suppresses + the prompt. PPR redirects all subsequent reads to the active profile once + it loads, so this stub is never read again after early engine init. + Only created if the file does not already exist. + """ + # Fix 1: empty Plugins.txt anchor for USVFS + appdata_sse = os.path.join(prefix_user, "AppData", "Local", "Skyrim Special Edition") + plugins_txt = os.path.join(appdata_sse, "Plugins.txt") + try: + os.makedirs(appdata_sse, exist_ok=True) + if not os.path.exists(plugins_txt): + open(plugins_txt, 'w').close() + self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") + else: + self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") + except Exception as e: + self.logger.warning(f"Could not create Plugins.txt anchor: {e}") + + # Fix 2: minimal SkyrimPrefs.ini at real Documents path to suppress AE popup + skyrimprefs_path = os.path.join( + prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini" + ) + try: + if not os.path.exists(skyrimprefs_path): + with open(skyrimprefs_path, 'w', encoding='utf-8') as f: + f.write("[General]\nbDownloadCC=1\n") + self.logger.info(f"Created SkyrimPrefs.ini stub to suppress AE popup: {skyrimprefs_path}") + else: + self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}") + except Exception as e: + self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}") + + def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: + """ + Pre-seed files in the Wine prefix that Fallout 4 needs on first launch. + + 1. AppData/Local/Fallout4/Plugins.txt - empty anchor file for USVFS. + Same mutex deadlock mechanism as Skyrim SE - confirmed to apply to FO4. + + INI stub for CC popup suppression is intentionally omitted until the correct + key name in Fallout4Prefs.ini is confirmed via testing. + """ + appdata_fo4 = os.path.join(prefix_user, "AppData", "Local", docs_dir_name) + plugins_txt = os.path.join(appdata_fo4, "Plugins.txt") + try: + os.makedirs(appdata_fo4, exist_ok=True) + if not os.path.exists(plugins_txt): + open(plugins_txt, 'w').close() + self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") + else: + self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") + except Exception as e: + self.logger.warning(f"Could not create Plugins.txt anchor: {e}") + + def _seed_starfield_first_launch_files( + self, prefix_user: str, docs_dir_name: str, modlist_dir: Optional[str] = None + ) -> None: + """ + Pre-seed files in the Wine prefix that Starfield needs on first launch. + + 1. AppData/Local/Starfield/Plugins.txt - empty anchor file for USVFS. + Same mechanism as Skyrim SE/FO4. + + 2. Documents/My Games/Starfield/StarfieldPrefs.ini and StarfieldCustom.ini - + minimal stubs. MO2's Starfield plugin has no bundled default-ini fallback + (unlike Skyrim/FO4, which fall back to a default ini shipped with the game). + It lists both files as required (GameStarfield::iniFiles()) and copies both + from this real path into the profile. On Windows they already exist because + the user has launched vanilla Starfield at least once; a fresh Jackify + install never does that, so MO2 finds nothing to copy from. + + 3. profiles//StarfieldPrefs.ini and StarfieldCustom.ini - + same stubs, written directly into the active MO2 profile. MO2's missing-ini + check (Profile::localSettingsEnabled) only looks at the profile folder itself, + which starts empty regardless of step 2 above - it always warns + "Missing profile-specific game INI files!" once unless the profile already + has these files before MO2 ever checks. Only written if not already present, + since a modlist's shipped profile may already carry real ini tweaks from + the author (observed for StarfieldCustom.ini in practice). + """ + appdata_starfield = os.path.join(prefix_user, "AppData", "Local", docs_dir_name) + plugins_txt = os.path.join(appdata_starfield, "Plugins.txt") + try: + os.makedirs(appdata_starfield, exist_ok=True) + if not os.path.exists(plugins_txt): + open(plugins_txt, 'w').close() + self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") + else: + self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") + except Exception as e: + self.logger.warning(f"Could not create Plugins.txt anchor: {e}") + + starfieldprefs_path = os.path.join( + prefix_user, "Documents", "My Games", docs_dir_name, "StarfieldPrefs.ini" + ) + try: + if not os.path.exists(starfieldprefs_path): + with open(starfieldprefs_path, 'w', encoding='utf-8') as f: + f.write("[Display]\n") + self.logger.info(f"Created StarfieldPrefs.ini stub for first launch: {starfieldprefs_path}") + else: + self.logger.debug(f"StarfieldPrefs.ini already exists, skipping: {starfieldprefs_path}") + except Exception as e: + self.logger.warning(f"Could not create StarfieldPrefs.ini stub: {e}") + + starfieldcustom_path = os.path.join( + prefix_user, "Documents", "My Games", docs_dir_name, "StarfieldCustom.ini" + ) + try: + if not os.path.exists(starfieldcustom_path): + with open(starfieldcustom_path, 'w', encoding='utf-8') as f: + f.write("[General]\n") + self.logger.info(f"Created StarfieldCustom.ini stub for first launch: {starfieldcustom_path}") + else: + self.logger.debug(f"StarfieldCustom.ini already exists, skipping: {starfieldcustom_path}") + except Exception as e: + self.logger.warning(f"Could not create StarfieldCustom.ini stub: {e}") + + if not modlist_dir: + return + + try: + from jackify.backend.utils.modlist_meta import _read_selected_profile + profile_name = _read_selected_profile(modlist_dir) + except Exception as e: + self.logger.debug(f"Could not read selected_profile for Starfield ini pre-seed: {e}") + return + + if not profile_name: + self.logger.debug("No selected_profile found, skipping Starfield profile ini pre-seed") + return + + profile_dir = os.path.join(modlist_dir, "profiles", profile_name) + try: + os.makedirs(profile_dir, exist_ok=True) + except Exception as e: + self.logger.warning(f"Could not create profile directory {profile_dir}: {e}") + return + + for filename, stub_content in ( + ("StarfieldPrefs.ini", "[Display]\n"), + ("StarfieldCustom.ini", "[General]\n"), + ): + profile_ini_path = os.path.join(profile_dir, filename) + try: + if not os.path.exists(profile_ini_path): + with open(profile_ini_path, 'w', encoding='utf-8') as f: + f.write(stub_content) + self.logger.info(f"Created {filename} stub in profile to suppress missing-ini warning: {profile_ini_path}") + else: + self.logger.debug(f"{filename} already exists in profile, skipping: {profile_ini_path}") + except Exception as e: + self.logger.warning(f"Could not create {filename} stub in profile: {e}") + + def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: + """ + Pre-seed files in the Wine prefix that Skyrim VR needs on first launch. + + 1. AppData/Local/Skyrim VR/Plugins.txt - empty anchor file for USVFS. + Same mutex deadlock mechanism as Skyrim SE applies to VR. + + 2. Documents/My Games/Skyrim VR/SkyrimPrefs.ini - minimal stub with two keys: + - bDownloadCC=1: suppresses the AE/CC download prompt (same engine behaviour + as Skyrim SE; fires before PPR hooks the INI API). + - bLoadVRPlayroom=0: prevents the game loading the Bethesda VR playroom + tutorial on first launch. Without this, SkyrimVR skips the main menu and + drops the user into the playroom, bypassing the modlist's startup sequence. + """ + appdata_vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name) + plugins_txt = os.path.join(appdata_vr, "Plugins.txt") + try: + os.makedirs(appdata_vr, exist_ok=True) + if not os.path.exists(plugins_txt): + open(plugins_txt, 'w').close() + self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") + else: + self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") + except Exception as e: + self.logger.warning(f"Could not create Plugins.txt anchor: {e}") + + skyrimprefs_path = os.path.join( + prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini" + ) + try: + if not os.path.exists(skyrimprefs_path): + with open(skyrimprefs_path, 'w', encoding='utf-8') as f: + f.write("[General]\nbDownloadCC=1\nbLoadVRPlayroom=0\n") + self.logger.info(f"Created SkyrimPrefs.ini stub for VR first-launch: {skyrimprefs_path}") + else: + self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}") + except Exception as e: + self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}") + + def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None: + """ + Pre-seed files in the Wine prefix that Fallout 4 VR needs on first launch. + + 1. AppData/Local/Fallout4VR/Plugins.txt - empty anchor file for USVFS. + Same mutex deadlock mechanism as Skyrim SE and FO4 applies to VR. + + INI stub is intentionally omitted - the correct key name in Fallout4VRPrefs.ini + has not been confirmed via testing. + """ + appdata_fo4vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name) + plugins_txt = os.path.join(appdata_fo4vr, "Plugins.txt") + try: + os.makedirs(appdata_fo4vr, exist_ok=True) + if not os.path.exists(plugins_txt): + open(plugins_txt, 'w').close() + self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}") + else: + self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}") + except Exception as e: + self.logger.warning(f"Could not create Plugins.txt anchor: {e}") diff --git a/jackify/backend/handlers/menu_handler_modlist.py b/jackify/backend/handlers/menu_handler_modlist.py index d5c40c0..a931a8a 100644 --- a/jackify/backend/handlers/menu_handler_modlist.py +++ b/jackify/backend/handlers/menu_handler_modlist.py @@ -237,6 +237,7 @@ class ModlistMenuHandler: mo2_dir = os.path.dirname(mo2_path) # --- Auto-create nxmhandler.ini to suppress NXM Handling popup (MOVED UP) --- self.shortcut_handler.write_nxmhandler_ini(mo2_dir, mo2_path) + self.shortcut_handler.repair_dlls_manifest(mo2_dir) steam_icons_path = os.path.join(mo2_dir, "Steam Icons") steamicons_path = os.path.join(mo2_dir, "SteamIcons") if os.path.isdir(steam_icons_path) and not os.path.isdir(steamicons_path): @@ -454,6 +455,7 @@ class ModlistMenuHandler: _mo2_dir = os.path.dirname(_mo2_exe) if _mo2_dir and os.path.isdir(_mo2_dir): self.shortcut_handler.write_nxmhandler_ini(_mo2_dir, _mo2_exe) + self.shortcut_handler.repair_dlls_manifest(_mo2_dir) # Robust AppID lookup for GUI/CLI: if appid missing but mo2_exe_path present, look it up if 'appid' not in context or not context.get('appid'): if 'mo2_exe_path' in context and context['mo2_exe_path']: diff --git a/jackify/backend/handlers/modlist_configuration.py b/jackify/backend/handlers/modlist_configuration.py index a885053..8ef2ab3 100644 --- a/jackify/backend/handlers/modlist_configuration.py +++ b/jackify/backend/handlers/modlist_configuration.py @@ -209,6 +209,7 @@ class ModlistConfigurationMixin: "fnv": "falloutnv", "falloutnv": "falloutnv", "oblivion": "oblivion", + "oblivion_remastered": "oblivion_remastered", "enderal": "enderalse", "enderalspecialedition": "enderalse", "bg3": "bg3", @@ -232,7 +233,9 @@ class ModlistConfigurationMixin: if game_name: appid_str = str(self.appid) - if self.filesystem_handler.create_required_dirs(game_name, appid_str): + if self.filesystem_handler.create_required_dirs( + game_name, appid_str, modlist_dir=str(self.modlist_dir) if self.modlist_dir else None + ): self.logger.info("Wine prefix Documents directories created successfully for USVFS") else: self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)") diff --git a/jackify/backend/handlers/modlist_install_cli_configuration.py b/jackify/backend/handlers/modlist_install_cli_configuration.py index 506bdd7..a43c54d 100644 --- a/jackify/backend/handlers/modlist_install_cli_configuration.py +++ b/jackify/backend/handlers/modlist_install_cli_configuration.py @@ -461,6 +461,7 @@ class ModlistInstallCLIConfigurationMixin: from .shortcut_handler import ShortcutHandler shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=False) shortcut_handler.write_nxmhandler_ini(install_dir_str, mo2_exe_path) + shortcut_handler.repair_dlls_manifest(install_dir_str) from ..services.automated_prefix_service import AutomatedPrefixService prefix_service = AutomatedPrefixService() diff --git a/jackify/backend/handlers/progress_state_processing.py b/jackify/backend/handlers/progress_state_processing.py index 9094641..a279b57 100644 --- a/jackify/backend/handlers/progress_state_processing.py +++ b/jackify/backend/handlers/progress_state_processing.py @@ -84,7 +84,16 @@ class ProgressStateProcessingMixin: updated = True if parsed.data_info: - self.state.data_processed, self.state.data_total = parsed.data_info + new_processed, new_total = parsed.data_info + self.state.data_processed = new_processed + if self.state.phase == InstallationPhase.DOWNLOAD and self._download_total_bytes > 0: + # The engine only reports remaining bytes during download; the parser + # extrapolates a total from it and that estimate drifts both up and + # down line to line as archive sizes vary. Prefer the total already + # accumulated from real per-file sizes below once it's available. + self.state.data_total = self._download_total_bytes + else: + self.state.data_total = new_total if self.state.data_total > 0 and self.state.overall_percent == 0.0: self.state.overall_percent = (self.state.data_processed / self.state.data_total) * 100.0 updated = True diff --git a/jackify/backend/handlers/shortcut_launch_options.py b/jackify/backend/handlers/shortcut_launch_options.py index a37b8c2..e2913ce 100644 --- a/jackify/backend/handlers/shortcut_launch_options.py +++ b/jackify/backend/handlers/shortcut_launch_options.py @@ -1,12 +1,15 @@ """Launch options and icon methods for ShortcutHandler (Mixin).""" import logging import os +import re import shutil import time import vdf logger = logging.getLogger(__name__) +_MANIFEST_FILE_RE = re.compile(r'', re.IGNORECASE) + class ShortcutLaunchOptionsMixin: """Mixin providing launch options and icon methods.""" @@ -258,20 +261,77 @@ class ShortcutLaunchOptionsMixin: logger.debug("[DEBUG] No SteamIcons directory found; shortcut will have no icon.") return "" + def repair_dlls_manifest(self, mo2_dir: str) -> None: + """ + Some MO2 builds ship a dlls/dlls.manifest (Windows SxS assembly manifest) that omits + an entry for a DLL actually present in dlls/. Wine's loader only redirects lookups for + files declared in the manifest, so an undeclared DLL is invisible to dependents even + though it exists on disk (e.g. Qt6Core.dll importing icuuc.dll) - ModOrganizer.exe then + fails to load entirely (status c0000135). Patch in any missing entries so the manifest + matches what's actually in the directory. Never removes existing entries. + """ + dlls_dir = os.path.join(mo2_dir, "dlls") + manifest_path = os.path.join(dlls_dir, "dlls.manifest") + if not os.path.isfile(manifest_path): + return + + try: + with open(manifest_path, "r", encoding="utf-8") as f: + content = f.read() + except Exception as e: + self.logger.debug(f"Could not read dlls.manifest: {e}") + return + + declared = {m.group(1).lower() for m in _MANIFEST_FILE_RE.finditer(content)} + try: + actual = {name for name in os.listdir(dlls_dir) if name.lower().endswith(".dll")} + except Exception as e: + self.logger.debug(f"Could not list dlls directory: {e}") + return + + missing = sorted(name for name in actual if name.lower() not in declared) + if not missing: + return + + if "" not in content: + self.logger.warning(f"dlls.manifest at {manifest_path} has unexpected format - skipping repair") + return + + insert = "".join(f' \n' for name in missing) + patched = content.replace("", insert + "") + + try: + with open(manifest_path, "w", encoding="utf-8") as f: + f.write(patched) + self.logger.info(f"Added missing dlls.manifest entries: {', '.join(missing)}") + except Exception as e: + self.logger.error(f"Failed to patch dlls.manifest: {e}") + def write_nxmhandler_ini(self, modlist_dir, mo2_exe_path): """ Create nxmhandler.ini in the modlist directory to suppress the NXM Handling popup on first MO2 launch. - If the file already exists, do nothing. The executable path will be written as Z:\\, matching MO2's format. + + Some modlists ship their own nxmhandler.ini bundled with the packaged MO2 install, + carrying handler entries from the author's own machine (wrong drive letters, paths + to other modlists). Only skip writing if an existing file already references this + modlist's own executable path - otherwise regenerate it, since stale foreign entries + can make MO2 fail to re-verify its self-registration on close. """ ini_path = os.path.join(modlist_dir, "nxmhandler.ini") - if os.path.exists(ini_path): - self.logger.info(f"nxmhandler.ini already exists at {ini_path}") - return abs_path = os.path.abspath(mo2_exe_path) z_path = f"Z:{abs_path}" win_path = z_path.replace('/', '\\') win_path = win_path.replace('\\', '\\\\') + + if os.path.exists(ini_path): + with open(ini_path, "r", encoding="utf-8") as f: + existing = f.read() + if win_path in existing: + self.logger.info(f"nxmhandler.ini already configured for this modlist at {ini_path}") + return + self.logger.info(f"nxmhandler.ini exists but does not reference this modlist, regenerating: {ini_path}") + content = ( "[handlers]\n" "size=1\n" diff --git a/jackify/backend/services/nuget_signature_service.py b/jackify/backend/services/nuget_signature_service.py new file mode 100644 index 0000000..6037b70 --- /dev/null +++ b/jackify/backend/services/nuget_signature_service.py @@ -0,0 +1,253 @@ +""" +NuGet package signature trust configuration for Wine prefixes. + +Synthesis compiles C# patchers under Wine using an installed .NET SDK, which +requires NuGet package signature validation to succeed. Split out of +tool_config_service.py to keep that file under the project's size guardrail. + +Old (2018-2021 era) Microsoft BCL packages carry timestamp signatures chaining +to legacy VeriSign/Symantec roots that modern distros have removed from their +trust stores. Wine seeds its Root store from the host bundle, so those roots +are missing in the prefix and NuGet fails restore with NU3028 ("timestamping +certificate is not trusted"). NuGet treats an untrusted root on the TIMESTAMP +chain as a hard error unconditionally (see Timestamp.Verify in NuGet.Client - +UntrustedRoot is logged as Error regardless of signatureValidationMode, +allowUntrustedRoot, or any env var), so the only fix is making the roots +actually trusted in the prefix's cert store. +""" + +import base64 +import hashlib +import logging +import os +import re +import struct +import subprocess +import tempfile +from pathlib import Path +from typing import Callable, Iterator, Optional, Tuple + +logger = logging.getLogger(__name__) + +# Every NuGet.Config we write pins nuget.org's repository signing certs by +# fingerprint with allowUntrustedRoot, so the SIGNING chain does not depend on +# the CA root chain being trusted. The TIMESTAMP chain is not covered by this +# (see module docstring) - that requires the root cert import below. +# Fingerprints are nuget.org's own signing certs (public, stable across signing +# key rotations) - see SulfurNitride/Fluorine-Manager commit d453ed91b6dc332fd45987c642e9d230efe870c8. +_NUGET_CONFIG_TEMPLATE = """ + + + + + + + + + + + + + + + +""" + +_PEM_CERT_RE = re.compile( + r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", re.DOTALL +) + +# Registry path Wine's crypt32 reads trusted roots from - the same location it +# seeds with the host CA bundle at prefix init. +_ROOT_STORE_KEY = "HKEY_LOCAL_MACHINE\\Software\\Microsoft\\SystemCertificates\\Root\\Certificates" + +CERT_CERT_PROP_ID = 32 + + +def _find_sdk_trusted_roots(prefix_path: Path) -> Optional[Tuple[Path, Path]]: + """ + Locate the newest installed .NET SDK's bundled trustedroots PEM files. + Returns (codesignctl.pem, timestampctl.pem) or None if not found. + """ + sdk_root = prefix_path / "drive_c" / "Program Files" / "dotnet" / "sdk" + if not sdk_root.is_dir(): + return None + + for version_dir in sorted(sdk_root.iterdir(), reverse=True): + trustedroots = version_dir / "trustedroots" + codesign = trustedroots / "codesignctl.pem" + timestamp = trustedroots / "timestampctl.pem" + if codesign.exists() and timestamp.exists(): + return codesign, timestamp + + return None + + +def _read_pem_bundle(bundle_path: Path) -> Iterator[bytes]: + """Yield DER-encoded certificates from a concatenated PEM bundle.""" + text = bundle_path.read_text(encoding="utf-8", errors="replace") + for match in _PEM_CERT_RE.finditer(text): + yield base64.b64decode("".join(match.group(1).split())) + + +def _cert_registry_blob(der: bytes) -> bytes: + """ + Serialize a certificate the way crypt32 stores it in the registry: a + property record of DWORD propid, DWORD reserved (1), DWORD length, then + the raw DER. A blob containing only the CERT_CERT_PROP_ID record is valid. + """ + return struct.pack(" str: + """Format a REG_BINARY value as regedit .reg hex syntax with line wrapping.""" + hex_bytes = [f"{b:02x}" for b in data] + lines = [] + line = f'"{name}"=hex:' + for hb in hex_bytes: + if len(line) + len(hb) + 2 > 76: + lines.append(line + "\\") + line = " " + line += hb + "," + lines.append(line.rstrip(",")) + return "\n".join(lines) + + +def _build_certs_reg_content(bundles: Tuple[Path, ...]) -> Tuple[str, int]: + """ + Build a .reg file adding every cert in the given PEM bundles to the Wine + prefix's Root store. Returns (reg_content, unique_cert_count). Re-importing + is idempotent - regedit overwrites identical keys in place. + """ + seen = set() + parts = ["Windows Registry Editor Version 5.00"] + for bundle in bundles: + for der in _read_pem_bundle(bundle): + thumbprint = hashlib.sha1(der).hexdigest().upper() + if thumbprint in seen: + continue + seen.add(thumbprint) + parts.append("") + parts.append(f"[{_ROOT_STORE_KEY}\\{thumbprint}]") + parts.append(_hex_reg_value("Blob", _cert_registry_blob(der))) + parts.append("") + return "\n".join(parts), len(seen) + + +def install_nuget_cert( + prefix_path: Path, + wine_bin: str, + log: Callable[[str], None], +) -> bool: + """ + Import the .NET SDK's bundled code-signing and timestamp trusted-root + certificates into the Wine prefix's Root cert store, by writing serialized + cert blobs directly into the registry via regedit. + + Regedit is the only mechanism that actually persists here: certutil.exe + and rundll32 cryptext.dll are unimplemented stubs in Wine, and crypt32 + cert store writes (X509Store.Add / CertAddCertificateContextToStore) under + GE-Proton report success but never reach wineserver's registry - the added + certs are visible only inside the importing process and are gone once it + exits (confirmed on CachyOS + GE-Proton10-14: store reported the add, + cross-process reg query and the on-disk .reg files never saw it). + + Requires the .NET SDK already installed in the prefix - the PEM bundles + ship inside the SDK and are the exact trust anchors NuGet itself uses for + package and timestamp signature validation on Linux. + """ + trusted_roots = _find_sdk_trusted_roots(prefix_path) + if trusted_roots is None: + log(".NET SDK trusted root bundles not found - skipping NuGet certificate import") + return False + + try: + reg_content, cert_count = _build_certs_reg_content(trusted_roots) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".reg", delete=False, encoding="utf-8" + ) as tf: + tf.write(reg_content) + reg_file = tf.name + + log(f"Importing {cert_count} code-signing and timestamp roots into Wine cert store...") + env = os.environ.copy() + env["WINEPREFIX"] = str(prefix_path) + env["WINEDEBUG"] = "-all" + env["WINEDLLOVERRIDES"] = "winemenubuilder.exe=d" + env["DISPLAY"] = env.get("DISPLAY", ":0") + + try: + result = subprocess.run( + [wine_bin, "regedit", reg_file], + env=env, + capture_output=True, + text=True, + timeout=120, + ) + finally: + try: + os.unlink(reg_file) + except Exception: + pass + + if result.returncode != 0: + log(f"Certificate registry import exited with code {result.returncode}") + log(f"stderr: {result.stderr[:500]}") + return False + + # wineserver batches registry writes in memory and flushes to the + # on-disk .reg files lazily. "wineserver -w" blocks until wineserver + # exits, forcing the flush - same pattern used elsewhere for registry + # writes (see modlist_wine_ops.py). + wineserver_bin = os.path.join(os.path.dirname(wine_bin), "wineserver") + if os.path.exists(wineserver_bin): + try: + subprocess.run( + [wineserver_bin, "-w"], env=env, timeout=60, capture_output=True, + ) + except Exception as e: + log(f"wineserver flush failed (non-fatal): {e}") + else: + log(f"wineserver not found at {wineserver_bin}; registry flush may not persist") + + log(f"Imported {cert_count} certificates into Wine cert store") + return True + + except Exception as e: + log(f"Failed to install NuGet certificates: {e}") + return False + + +def configure_nuget_signature_policy( + prefix_path: Path, + log: Callable[[str], None], +) -> bool: + """ + Write a NuGet.Config that pins nuget.org's signing certificates by + fingerprint with allowUntrustedRoot, so signing-chain validation does not + depend on the CA root chain. Timestamp-chain trust is handled separately + by install_nuget_cert (see module docstring). + + Steam/Proton prefixes always use "steamuser" as the Windows user. + + Must run before the first dotnet invocation in the prefix: the SDK + auto-generates a bare-bones NuGet.Config (no trust policy) as a side + effect of any restore if none exists yet, and this function only writes + when the file is absent. + """ + config_dir = prefix_path / "drive_c" / "users" / "steamuser" / "AppData" / "Roaming" / "NuGet" + config_path = config_dir / "NuGet.Config" + + if config_path.exists(): + log("NuGet.Config already exists - leaving it unchanged") + return True + + try: + config_dir.mkdir(parents=True, exist_ok=True) + config_path.write_text(_NUGET_CONFIG_TEMPLATE, encoding="utf-8") + log("NuGet signature policy configured (nuget.org signers trusted by fingerprint)") + return True + except Exception as e: + log(f"Failed to write NuGet.Config: {e}") + return False diff --git a/jackify/backend/services/resolution_service.py b/jackify/backend/services/resolution_service.py index b95a0e2..b129cfc 100644 --- a/jackify/backend/services/resolution_service.py +++ b/jackify/backend/services/resolution_service.py @@ -18,7 +18,31 @@ class ResolutionService: Centralized service for managing resolution settings Handles saving, loading, and validation of resolution settings """ - + + COMMON_RESOLUTIONS = [ + "1280x720", + "1280x800 (Steam Deck)", + "1366x768", + "1440x900", + "1600x900", + "1600x1200", + "1680x1050", + "1920x1080", + "1920x1200", + "2048x1152", + "2560x1080", + "2560x1440", + "2560x1600", + "3440x1440", + "3840x1080", + "3840x1600", + "3840x2160", + "3840x2400", + "5120x1440", + "5120x2160", + "7680x4320", + ] + def __init__(self): """Initialize the resolution service""" self.config_handler = ConfigHandler() @@ -140,6 +164,37 @@ class ResolutionService: logger.warning(f"Resolution does not match WxH format: {resolution}") return False + def get_resolution_list(self) -> list: + """ + Build the resolution dropdown list: the curated common resolutions, + plus any resolution reported by a connected Qt screen that isn't + already covered, so unusual native resolutions (e.g. ultrawide) + still show up. Falls back to the curated list alone if screen + detection is unavailable (headless, sandboxed launch, etc). + + Returns: + list: Resolution labels sorted by pixel dimensions + """ + resolutions = {} + for label in self.COMMON_RESOLUTIONS: + key = label.split(' ')[0] + resolutions[key] = label + + try: + from PySide6.QtGui import QGuiApplication + for screen in QGuiApplication.screens(): + size = screen.size() + key = f"{size.width()}x{size.height()}" + resolutions.setdefault(key, key) + except Exception as e: + logger.debug(f"Could not query Qt screens for resolution detection: {e}") + + def sort_key(label: str): + width, height = label.split(' ')[0].split('x') + return (int(width), int(height)) + + return sorted(resolutions.values(), key=sort_key) + def get_resolution_index(self, resolution: str, combo_items: list) -> int: """ Get the index of a resolution in a combo box list diff --git a/jackify/backend/services/tool_config_service.py b/jackify/backend/services/tool_config_service.py index 2457cd4..e8214b7 100644 --- a/jackify/backend/services/tool_config_service.py +++ b/jackify/backend/services/tool_config_service.py @@ -18,6 +18,11 @@ import zipfile from pathlib import Path from typing import Callable, Optional +from jackify.backend.services.nuget_signature_service import ( + configure_nuget_signature_policy, + install_nuget_cert, +) + logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- @@ -90,6 +95,30 @@ def _build_reg_content() -> str: lines.append(f'"{dll}"="native,builtin"') lines.append("") + # Disable MSBuild/Roslyn shared compiler server. Under Wine, VBCSCompiler's + # named pipe IPC is unreliable (mono/mono#11406), so each dotnet build falls + # back to spawning its own dotnet.exe instead of reusing one server process. + # Synthesis compiles one patcher per mod - without this, those pile up + # unreaped and can consume tens of GB of RAM. Writing to HKCU\Environment is + # the registry equivalent of setx, so every process in the prefix (including + # Synthesis's internal dotnet build calls) inherits these automatically. + # + # The two NUGET_* entries are needed for NuGet package signature validation + # (Synthesis fails to compile patchers without it): offline revocation mode + # avoids online CRL/OCSP checks that routinely fail or time out under Wine's + # sandboxed networking, and the experimental chain-build retry policy works + # around a known NuGet flake where the first chain build after a fresh cert + # import races and fails (NuGet/Home#11099). Both are written here rather + # than only passed to our own subprocess calls so Synthesis itself inherits + # them when launched later via Steam, matching Fluorine-Manager's + # confirmed-working NuGet signature fix. + lines.append("[HKEY_CURRENT_USER\\Environment]") + lines.append('"UseSharedCompilation"="false"') + lines.append('"MSBUILDDISABLENODEREUSE"="1"') + lines.append('"NUGET_CERT_REVOCATION_MODE"="offline"') + lines.append('"NUGET_EXPERIMENTAL_CHAIN_BUILD_RETRY_POLICY"="10,1000"') + lines.append("") + return "\r\n".join(lines) @@ -104,13 +133,6 @@ _DOTNET9_SDK_FILENAME = "dotnet-sdk-9.0.310-win-x64.zip" _DOTNET10_DESKTOP_URL = "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.2/windowsdesktop-runtime-10.0.2-win-x64.exe" _DOTNET10_DESKTOP_FILENAME = "windowsdesktop-runtime-10.0.2-win-x64.exe" -# DigiCert Universal Root CA - required for NuGet package signature validation. -# Without this, dotnet fails to verify NuGet package signatures when Synthesis -# compiles patchers. Imported into the Wine prefix Windows cert store so no -# system-level changes are needed. -_DIGICERT_CERT_URL = "https://cacerts.digicert.com/DigiCertTrustedRootG4.crt.pem" -_DIGICERT_CERT_FILENAME = "DigiCertTrustedRootG4.crt.pem" - # fxc2 build of d3dcompiler_47 - required for Community Shaders shader compilation. # The winetricks-provided d3dcompiler_47 lacks support for certain shader models # used by Community Shaders, causing "failed shaders" during compilation. @@ -204,56 +226,6 @@ def _install_dotnet10_desktop_runtime( return False -def _install_nuget_cert( - prefix_path: Path, - wine_bin: str, - log: Callable[[str], None], -) -> bool: - """ - Import the DigiCert Trusted Root G4 CA into the Wine prefix Windows cert - store. Required for NuGet package signature validation when Synthesis - compiles patchers. Uses wine certutil so no system-level changes are needed. - """ - try: - from jackify.shared.paths import get_jackify_data_dir - cache_dir = get_jackify_data_dir() / "cache" - cache_dir.mkdir(parents=True, exist_ok=True) - cert_file = cache_dir / _DIGICERT_CERT_FILENAME - - if not cert_file.exists(): - log(f"Downloading DigiCert Trusted Root G4 certificate...") - urllib.request.urlretrieve(_DIGICERT_CERT_URL, cert_file) - log("Certificate downloaded") - else: - log("DigiCert certificate already cached, skipping download") - - log("Importing certificate into Wine prefix cert store...") - env = os.environ.copy() - env["WINEPREFIX"] = str(prefix_path) - env["WINEDEBUG"] = "-all" - env["WINEDLLOVERRIDES"] = "winemenubuilder.exe=d" - env["DISPLAY"] = env.get("DISPLAY", ":0") - - result = subprocess.run( - [wine_bin, "certutil", "-addstore", "Root", str(cert_file)], - env=env, - capture_output=True, - text=True, - timeout=60, - ) - - if result.returncode != 0: - log(f"certutil exited with code {result.returncode} (may already be installed)") - else: - log("DigiCert certificate imported into Wine cert store") - return True - - except Exception as e: - log(f"Failed to install NuGet certificate: {e}") - return False - - - def _install_fxc2_d3dcompiler( prefix_path: Path, log: Callable[[str], None], @@ -360,8 +332,9 @@ def apply_tool_config( prefix to Windows 11, which are required for Synthesis. Intentionally opt-in - the download is ~220MB and the win11 flip has not been verified against NSF/CSF prefixes (see preserve_global_mscoree). - The NuGet Root CA cert (also required for Synthesis) is applied regardless of - this flag, since it has no interaction with mscoree hosting or Windows version. + The NuGet Root CA certs (also required for Synthesis) are only imported when + the SDK is present, since the trusted-root PEM bundles ship inside the SDK + (see nuget_signature_service.install_nuget_cert). install_fxc2_d3dcompiler=True replaces d3dcompiler_47.dll with the Mozilla fxc2 build. Only appropriate for Skyrim SE/AE modlists using Community Shaders. @@ -387,12 +360,6 @@ def apply_tool_config( _install_dotnet10_desktop_runtime(prefix_path, wine_bin, _log) _set_windows_version_win11(prefix_path, wine_bin, _log) - # NuGet cert import is independent of the SDK/win11 install above - it only adds a - # Root CA entry and has no interaction with mscoree hosting, so it must not be - # skipped for NSF/CSF modlists (which pass install_dotnet9_sdk=False to avoid the - # win11 flip). Synthesis still needs this cert even on an NSF/CSF prefix. - _install_nuget_cert(prefix_path, wine_bin, _log) - # Remove legacy global *mscoree=native from DllOverrides if present. # Old installs wrote this globally, which breaks .NET 9/10 bootstrap (Synthesis). # The targeted AppDefaults\SkyrimSE.exe entry written below replaces it. @@ -416,7 +383,15 @@ def apply_tool_config( except Exception as e: _log(f"Note: could not remove legacy mscoree entry (non-fatal): {e}") + # _build_reg_content() writes UseSharedCompilation=false and + # MSBUILDDISABLENODEREUSE=1, which prevent the Roslyn shared compiler server + # hang under Wine (see module docstring history). Must be in place before + # anything invokes dotnet in the prefix - without these keys the first + # "dotnet run" can hang on VBCSCompiler's named pipe and leave an orphaned + # dotnet.exe/winedevice.exe spinning at high CPU indefinitely (confirmed + # reproducible during the original cert-import ordering bug). reg_content = _build_reg_content() + regedit_ok = False try: with tempfile.NamedTemporaryFile( @@ -441,23 +416,35 @@ def apply_tool_config( if result.returncode != 0: _log(f"wine regedit exited with code {result.returncode}: {result.stderr[:200]}") - return False - - _log(f"Tool compatibility settings applied ({len(_XEDIT_EXECUTABLES)} xEdit variants, Pandora, {len(_DLL_OVERRIDES)} DLL overrides)") - return True + else: + _log(f"Tool compatibility settings applied ({len(_XEDIT_EXECUTABLES)} xEdit variants, Pandora, {len(_DLL_OVERRIDES)} DLL overrides)") + regedit_ok = True except subprocess.TimeoutExpired: _log("wine regedit timed out after 30 seconds") - return False except Exception as e: _log(f"Failed to apply tool config: {e}") - return False finally: try: os.unlink(reg_file) except Exception: pass + # Must run before the first dotnet invocation in this prefix: the SDK + # auto-generates a bare-bones NuGet.Config (nuget.org source only, no trust + # policy) as a side effect of any restore if none exists yet, and + # configure_nuget_signature_policy skips writing when the file already + # exists - so a stub landing first means our trust policy never applies. + configure_nuget_signature_policy(prefix_path, _log) + + # NuGet cert import requires the .NET SDK already present (the trusted-root + # PEM bundles ship inside the SDK). On NSF/CSF prefixes + # (install_dotnet9_sdk=False) the SDK is not installed, so this is skipped + # there - those modlists don't run Synthesis in the same prefix. + install_nuget_cert(prefix_path, wine_bin, _log) + + return regedit_ok + def setup_nemesis_compatibility( modlist_dir: str, diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_ui.py b/jackify/frontends/gui/screens/configure_existing_modlist_ui.py index 96fbb03..a600efd 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist_ui.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist_ui.py @@ -144,28 +144,7 @@ class ConfigureExistingModlistUIMixin: self.resolution_combo = QComboBox() self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.resolution_combo.addItem("Leave unchanged") - self.resolution_combo.addItems([ - "1280x720", - "1280x800 (Steam Deck)", - "1366x768", - "1440x900", - "1600x900", - "1600x1200", - "1680x1050", - "1920x1080", - "1920x1200", - "2048x1152", - "2560x1080", - "2560x1440", - "2560x1600", - "3440x1440", - "3840x1600", - "3840x2160", - "3840x2400", - "5120x1440", - "5120x2160", - "7680x4320" - ]) + self.resolution_combo.addItems(self.resolution_service.get_resolution_list()) form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addWidget(self.resolution_combo, 2, 1) 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 4a6e434..663e280 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py @@ -140,28 +140,7 @@ class ConfigureNewModlistUISetupMixin: self.resolution_combo = QComboBox() self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.resolution_combo.addItem("Leave unchanged") - self.resolution_combo.addItems([ - "1280x720", - "1280x800 (Steam Deck)", - "1366x768", - "1440x900", - "1600x900", - "1600x1200", - "1680x1050", - "1920x1080", - "1920x1200", - "2048x1152", - "2560x1080", - "2560x1440", - "2560x1600", - "3440x1440", - "3840x1600", - "3840x2160", - "3840x2400", - "5120x1440", - "5120x2160", - "7680x4320" - ]) + self.resolution_combo.addItems(self.resolution_service.get_resolution_list()) form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) # Load saved resolution if available diff --git a/jackify/frontends/gui/screens/install_modlist_ui_setup.py b/jackify/frontends/gui/screens/install_modlist_ui_setup.py index 4f9f382..2e3919c 100644 --- a/jackify/frontends/gui/screens/install_modlist_ui_setup.py +++ b/jackify/frontends/gui/screens/install_modlist_ui_setup.py @@ -259,28 +259,7 @@ class InstallModlistUISetupMixin: self.resolution_combo = QComboBox() self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.resolution_combo.addItem("Leave unchanged") - self.resolution_combo.addItems([ - "1280x720", - "1280x800 (Steam Deck)", - "1366x768", - "1440x900", - "1600x900", - "1600x1200", - "1680x1050", - "1920x1080", - "1920x1200", - "2048x1152", - "2560x1080", - "2560x1440", - "2560x1600", - "3440x1440", - "3840x1600", - "3840x2160", - "3840x2400", - "5120x1440", - "5120x2160", - "7680x4320" - ]) + self.resolution_combo.addItems(self.resolution_service.get_resolution_list()) # Load saved resolution if available saved_resolution = self.resolution_service.get_saved_resolution() is_steam_deck = False diff --git a/jackify/frontends/gui/widgets/file_progress_list.py b/jackify/frontends/gui/widgets/file_progress_list.py index 66cfea6..b6f974f 100644 --- a/jackify/frontends/gui/widgets/file_progress_list.py +++ b/jackify/frontends/gui/widgets/file_progress_list.py @@ -343,10 +343,19 @@ class FileProgressList(QWidget): def stop_cpu_tracking(self): self._cpu_timer.stop() - if self._cpu_worker and self._cpu_worker.isRunning(): - self._cpu_worker.quit() - self._cpu_worker.wait(1000) - self._cpu_worker = None + worker = self._cpu_worker + if worker is None: + return + self._cpu_worker = None + if not worker.isRunning(): + return + worker.wait(2000) + if worker.isRunning(): + # psutil scan (baselining new child processes) didn't finish in time. + # Dropping the last Python reference to a still-running QThread makes + # Qt abort ("QThread: Destroyed while thread is still running") - let + # it finish naturally and clean itself up instead. + worker.finished.connect(worker.deleteLater) def _start_cpu_worker(self): # Skip if a worker is already running to avoid pileup diff --git a/jackify/tools/verify_install.py b/jackify/tools/verify_install.py index 750ee40..2c4f651 100755 --- a/jackify/tools/verify_install.py +++ b/jackify/tools/verify_install.py @@ -524,7 +524,7 @@ def check_user_directories(pfx: Path, game_type: str, r: Results): if my_games.is_dir(): r.ok(f"My Games/{MY_GAMES_DIR[game_type]} exists") else: - r.fail(f"My Games/{MY_GAMES_DIR[game_type]} missing") + r.warn(f"My Games/{MY_GAMES_DIR[game_type]} missing") # AppData/Local if game_type in APPDATA_LOCAL_DIRS: @@ -1124,8 +1124,13 @@ def check_tool_compat_config(pfx: Path, game_type: str, r: Results): # .NET 9 SDK is installed natively (not via winetricks) for Synthesis. # NSF/CSF prefixes use global *mscoree=native and deliberately skip dotnet9 (win11 flip - # breaks NSF), so suppress the check when that override is present. - _nsf_prefix = '"*mscoree"="native"' in content + # breaks NSF), so suppress the check when that override is present. Must check the + # global [Software\Wine\DllOverrides] section specifically, not just any occurrence of + # the string - every Skyrim modlist (NSF or not) also gets a scoped + # AppDefaults\SkyrimSE.exe\DllOverrides entry with the same value, which a plain + # substring search can't tell apart from the global one. + _global_overrides_match = re.search(r"\[Software\\\\Wine\\\\DllOverrides\][^\[]*", content) + _nsf_prefix = bool(_global_overrides_match and '"*mscoree"="native"' in _global_overrides_match.group(0)) sdk_base = pfx / "drive_c" / "Program Files" / "dotnet" / "sdk" if _nsf_prefix: r.ok(".NET 9 SDK check skipped (NSF/CSF prefix - global mscoree=native detected)") @@ -1139,6 +1144,49 @@ def check_tool_compat_config(pfx: Path, game_type: str, r: Results): else: r.warn(".NET 9 SDK not found (drive_c/Program Files/dotnet/sdk/ missing; run Configure Tool Compatibility)") + if not _nsf_prefix: + sys_content = system_reg.read_text(errors="replace") if system_reg.exists() else "" + _check_nuget_signature_config(pfx, content, sys_content, r) + + +def _check_nuget_signature_config(pfx: Path, user_reg_content: str, system_reg_content: str, r: Results): + """ + Check NuGet package signature validation is configured for Synthesis. + Three independent pieces, all required (see nuget_signature_service.py): + the trust-pin NuGet.Config, the SDK's own trusted-root certs imported into + Wine's cert store, and the offline-revocation/retry-policy env vars. + """ + config_path = pfx / "drive_c" / "users" / "steamuser" / "AppData" / "Roaming" / "NuGet" / "NuGet.Config" + if not config_path.exists(): + r.warn("NuGet.Config not found (Synthesis package restore will likely fail)") + else: + config_content = config_path.read_text(errors="replace") + if "allowUntrustedRoot" in config_content and "trustedSigners" in config_content: + r.ok("NuGet signature trust policy configured") + else: + r.warn( + "NuGet.Config exists but lacks the trust policy - likely the SDK's " + "auto-generated stub (dotnet wrote it before Jackify could); delete " + "it and re-run Configure Tool Compatibility" + ) + + # X509Store(StoreName.Root, StoreLocation.CurrentUser) lands in user.reg + # (HKCU) on some Wine versions and system.reg (HKLM) on others - confirmed + # via a real CachyOS repro (wine-11.0) where the import genuinely succeeded + # but landed in system.reg while an older Wine build on another machine put + # it in user.reg. Check both rather than assume one. + marker = "SystemCertificates\\\\Root\\\\Certificates\\\\" + root_cert_count = user_reg_content.count(marker) + system_reg_content.count(marker) + if root_cert_count > 0: + r.ok(f".NET SDK trusted-root certs imported into Wine cert store ({root_cert_count} entries)") + else: + r.warn(".NET SDK trusted-root certs not found in Wine cert store") + + if "NUGET_CERT_REVOCATION_MODE" in user_reg_content and "NUGET_EXPERIMENTAL_CHAIN_BUILD_RETRY_POLICY" in user_reg_content: + r.ok("NuGet offline-revocation/retry-policy env vars set") + else: + r.warn("NuGet offline-revocation/retry-policy env vars not found in HKCU\\Environment") + def check_steam_artwork(appid: str, r: Results): """Check whether Steam grid artwork was applied for this AppID."""