mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 01:23:46 +02:00
Release v0.7.1.1 - Synthesis, Starfield, and Stability Fixes
This commit is contained in:
@@ -1,5 +1,21 @@
|
|||||||
# Jackify Changelog
|
# 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
|
## v0.7.1 - Remote Manifest System, Stability Fixes
|
||||||
**Release Date:** 2026-07-02
|
**Release Date:** 2026-07-02
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
|||||||
Wabbajack modlists natively on Linux systems.
|
Wabbajack modlists natively on Linux systems.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.7.1"
|
__version__ = "0.7.1.1"
|
||||||
|
|||||||
@@ -64,18 +64,18 @@ Windows Registry Editor Version 5.00
|
|||||||
"xactengine3_5"="native,builtin"
|
"xactengine3_5"="native,builtin"
|
||||||
"xactengine3_6"="native,builtin"
|
"xactengine3_6"="native,builtin"
|
||||||
"xactengine3_7"="native,builtin"
|
"xactengine3_7"="native,builtin"
|
||||||
"atl110"="native,builtin"
|
"*atl110"="native,builtin"
|
||||||
"msvcp110"="native,builtin"
|
"*msvcp110"="native,builtin"
|
||||||
"msvcr110"="native,builtin"
|
"*msvcr110"="native,builtin"
|
||||||
"vcomp110"="native,builtin"
|
"*vcomp110"="native,builtin"
|
||||||
"concrt140"="native,builtin"
|
"*concrt140"="native,builtin"
|
||||||
"msvcp140"="native,builtin"
|
"*msvcp140"="native,builtin"
|
||||||
"msvcp140_1"="native,builtin"
|
"*msvcp140_1"="native,builtin"
|
||||||
"msvcp140_2"="native,builtin"
|
"*msvcp140_2"="native,builtin"
|
||||||
"msvcp140_atomic_wait"="native,builtin"
|
"*msvcp140_atomic_wait"="native,builtin"
|
||||||
"msvcp140_codecvt_ids"="native,builtin"
|
"*msvcp140_codecvt_ids"="native,builtin"
|
||||||
"vcamp140"="native,builtin"
|
"*vcamp140"="native,builtin"
|
||||||
"vccorlib140"="native,builtin"
|
"*vccorlib140"="native,builtin"
|
||||||
"vcomp140"="native,builtin"
|
"*vcomp140"="native,builtin"
|
||||||
"vcruntime140"="native,builtin"
|
"*vcruntime140"="native,builtin"
|
||||||
"vcruntime140_1"="native,builtin"
|
"*vcruntime140_1"="native,builtin"
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET
|
|||||||
from .filesystem_handler_download import FilesystemDownloadMixin
|
from .filesystem_handler_download import FilesystemDownloadMixin
|
||||||
from .filesystem_handler_ownership import FilesystemOwnershipMixin
|
from .filesystem_handler_ownership import FilesystemOwnershipMixin
|
||||||
from .filesystem_handler_steam import FilesystemSteamMixin
|
from .filesystem_handler_steam import FilesystemSteamMixin
|
||||||
|
from .filesystem_handler_first_launch import FilesystemFirstLaunchMixin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class FileSystemHandler(
|
||||||
class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, FilesystemSteamMixin):
|
FilesystemDownloadMixin, FilesystemOwnershipMixin, FilesystemSteamMixin, FilesystemFirstLaunchMixin
|
||||||
|
):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.logger = logging.getLogger(__name__)
|
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}")
|
logger.error(f"Failed to add backupPath entry to {modlist_ini}: {e}")
|
||||||
return False # Backup succeeded, but adding entry failed
|
return False # Backup succeeded, but adding entry failed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def copy_file(self, src: Path, dst: Path, overwrite: bool = False) -> bool:
|
def copy_file(self, src: Path, dst: Path, overwrite: bool = False) -> bool:
|
||||||
"""
|
"""
|
||||||
Copy a file from source to destination.
|
Copy a file from source to destination.
|
||||||
@@ -474,7 +474,7 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
|
|||||||
self.logger.error(f"Error deleting directory: {e}")
|
self.logger.error(f"Error deleting directory: {e}")
|
||||||
return False
|
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
|
Create required directories for a game modlist
|
||||||
|
|
||||||
@@ -486,7 +486,8 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
|
|||||||
Args:
|
Args:
|
||||||
game_name: Name of the game (e.g., skyrimse, fallout4)
|
game_name: Name of the game (e.g., skyrimse, fallout4)
|
||||||
appid: Steam AppID of the modlist
|
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:
|
Returns:
|
||||||
bool: True if directories were created successfully, False otherwise
|
bool: True if directories were created successfully, False otherwise
|
||||||
"""
|
"""
|
||||||
@@ -502,8 +503,10 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
|
|||||||
"fallout4vr": "Fallout4VR",
|
"fallout4vr": "Fallout4VR",
|
||||||
"falloutnv": "FalloutNV",
|
"falloutnv": "FalloutNV",
|
||||||
"oblivion": "Oblivion",
|
"oblivion": "Oblivion",
|
||||||
|
"oblivion_remastered": "Oblivion Remastered",
|
||||||
"enderal": "Enderal Special Edition",
|
"enderal": "Enderal Special Edition",
|
||||||
"enderalse": "Enderal Special Edition",
|
"enderalse": "Enderal Special Edition",
|
||||||
|
"starfield": "Starfield",
|
||||||
}
|
}
|
||||||
|
|
||||||
game_dirs = {
|
game_dirs = {
|
||||||
@@ -585,6 +588,8 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
|
|||||||
self._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
|
self._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
|
||||||
elif game_name == "fallout4vr":
|
elif game_name == "fallout4vr":
|
||||||
self._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
|
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:
|
else:
|
||||||
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix directory creation")
|
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}")
|
self.logger.error(f"Error creating required directories: {e}")
|
||||||
return False
|
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}")
|
|
||||||
|
|||||||
@@ -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/<selected profile>/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}")
|
||||||
@@ -237,6 +237,7 @@ class ModlistMenuHandler:
|
|||||||
mo2_dir = os.path.dirname(mo2_path)
|
mo2_dir = os.path.dirname(mo2_path)
|
||||||
# --- Auto-create nxmhandler.ini to suppress NXM Handling popup (MOVED UP) ---
|
# --- Auto-create nxmhandler.ini to suppress NXM Handling popup (MOVED UP) ---
|
||||||
self.shortcut_handler.write_nxmhandler_ini(mo2_dir, mo2_path)
|
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")
|
steam_icons_path = os.path.join(mo2_dir, "Steam Icons")
|
||||||
steamicons_path = os.path.join(mo2_dir, "SteamIcons")
|
steamicons_path = os.path.join(mo2_dir, "SteamIcons")
|
||||||
if os.path.isdir(steam_icons_path) and not os.path.isdir(steamicons_path):
|
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)
|
_mo2_dir = os.path.dirname(_mo2_exe)
|
||||||
if _mo2_dir and os.path.isdir(_mo2_dir):
|
if _mo2_dir and os.path.isdir(_mo2_dir):
|
||||||
self.shortcut_handler.write_nxmhandler_ini(_mo2_dir, _mo2_exe)
|
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
|
# 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 'appid' not in context or not context.get('appid'):
|
||||||
if 'mo2_exe_path' in context and context['mo2_exe_path']:
|
if 'mo2_exe_path' in context and context['mo2_exe_path']:
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ class ModlistConfigurationMixin:
|
|||||||
"fnv": "falloutnv",
|
"fnv": "falloutnv",
|
||||||
"falloutnv": "falloutnv",
|
"falloutnv": "falloutnv",
|
||||||
"oblivion": "oblivion",
|
"oblivion": "oblivion",
|
||||||
|
"oblivion_remastered": "oblivion_remastered",
|
||||||
"enderal": "enderalse",
|
"enderal": "enderalse",
|
||||||
"enderalspecialedition": "enderalse",
|
"enderalspecialedition": "enderalse",
|
||||||
"bg3": "bg3",
|
"bg3": "bg3",
|
||||||
@@ -232,7 +233,9 @@ class ModlistConfigurationMixin:
|
|||||||
|
|
||||||
if game_name:
|
if game_name:
|
||||||
appid_str = str(self.appid)
|
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")
|
self.logger.info("Wine prefix Documents directories created successfully for USVFS")
|
||||||
else:
|
else:
|
||||||
self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)")
|
self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)")
|
||||||
|
|||||||
@@ -461,6 +461,7 @@ class ModlistInstallCLIConfigurationMixin:
|
|||||||
from .shortcut_handler import ShortcutHandler
|
from .shortcut_handler import ShortcutHandler
|
||||||
shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=False)
|
shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=False)
|
||||||
shortcut_handler.write_nxmhandler_ini(install_dir_str, mo2_exe_path)
|
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
|
from ..services.automated_prefix_service import AutomatedPrefixService
|
||||||
prefix_service = AutomatedPrefixService()
|
prefix_service = AutomatedPrefixService()
|
||||||
|
|||||||
@@ -84,7 +84,16 @@ class ProgressStateProcessingMixin:
|
|||||||
updated = True
|
updated = True
|
||||||
|
|
||||||
if parsed.data_info:
|
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:
|
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
|
self.state.overall_percent = (self.state.data_processed / self.state.data_total) * 100.0
|
||||||
updated = True
|
updated = True
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"""Launch options and icon methods for ShortcutHandler (Mixin)."""
|
"""Launch options and icon methods for ShortcutHandler (Mixin)."""
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
import vdf
|
import vdf
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MANIFEST_FILE_RE = re.compile(r'<file\s+name="([^"]+)"\s*/>', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
class ShortcutLaunchOptionsMixin:
|
class ShortcutLaunchOptionsMixin:
|
||||||
"""Mixin providing launch options and icon methods."""
|
"""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.")
|
logger.debug("[DEBUG] No SteamIcons directory found; shortcut will have no icon.")
|
||||||
return ""
|
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 "</assembly>" not in content:
|
||||||
|
self.logger.warning(f"dlls.manifest at {manifest_path} has unexpected format - skipping repair")
|
||||||
|
return
|
||||||
|
|
||||||
|
insert = "".join(f' <file name="{name}" />\n' for name in missing)
|
||||||
|
patched = content.replace("</assembly>", insert + "</assembly>")
|
||||||
|
|
||||||
|
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):
|
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.
|
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:\\<absolute path with double backslashes>, matching MO2's format.
|
The executable path will be written as Z:\\<absolute path with double backslashes>, 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")
|
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)
|
abs_path = os.path.abspath(mo2_exe_path)
|
||||||
z_path = f"Z:{abs_path}"
|
z_path = f"Z:{abs_path}"
|
||||||
win_path = z_path.replace('/', '\\')
|
win_path = z_path.replace('/', '\\')
|
||||||
win_path = win_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 = (
|
content = (
|
||||||
"[handlers]\n"
|
"[handlers]\n"
|
||||||
"size=1\n"
|
"size=1\n"
|
||||||
|
|||||||
@@ -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 = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<config>
|
||||||
|
<add key="signatureValidationMode" value="accept" />
|
||||||
|
</config>
|
||||||
|
<packageSources>
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||||
|
</packageSources>
|
||||||
|
<trustedSigners>
|
||||||
|
<repository name="nuget.org" serviceIndex="https://api.nuget.org/v3/index.json">
|
||||||
|
<certificate fingerprint="0E5F38F57DC1BCC806D8494F4F90FBCEDD988B46760709CBEEC6F4219AA6157D" hashAlgorithm="SHA256" allowUntrustedRoot="true" />
|
||||||
|
<certificate fingerprint="5A2901D6ADA3D18260B9C6DFE2133C95D74B9EEF6AE0E5DC334C8454D1477DF4" hashAlgorithm="SHA256" allowUntrustedRoot="true" />
|
||||||
|
<certificate fingerprint="1F4B311D9ACC115C8DC8018B5A49E00FCE6DA8E2855F9F014CA6F34570BC482D" hashAlgorithm="SHA256" allowUntrustedRoot="true" />
|
||||||
|
</repository>
|
||||||
|
</trustedSigners>
|
||||||
|
</configuration>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_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("<III", CERT_CERT_PROP_ID, 1, len(der)) + der
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_reg_value(name: str, data: bytes) -> 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
|
||||||
@@ -19,6 +19,30 @@ class ResolutionService:
|
|||||||
Handles saving, loading, and validation of 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):
|
def __init__(self):
|
||||||
"""Initialize the resolution service"""
|
"""Initialize the resolution service"""
|
||||||
self.config_handler = ConfigHandler()
|
self.config_handler = ConfigHandler()
|
||||||
@@ -140,6 +164,37 @@ class ResolutionService:
|
|||||||
logger.warning(f"Resolution does not match WxH format: {resolution}")
|
logger.warning(f"Resolution does not match WxH format: {resolution}")
|
||||||
return False
|
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:
|
def get_resolution_index(self, resolution: str, combo_items: list) -> int:
|
||||||
"""
|
"""
|
||||||
Get the index of a resolution in a combo box list
|
Get the index of a resolution in a combo box list
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import zipfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from jackify.backend.services.nuget_signature_service import (
|
||||||
|
configure_nuget_signature_policy,
|
||||||
|
install_nuget_cert,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -90,6 +95,30 @@ def _build_reg_content() -> str:
|
|||||||
lines.append(f'"{dll}"="native,builtin"')
|
lines.append(f'"{dll}"="native,builtin"')
|
||||||
lines.append("")
|
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)
|
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_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"
|
_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.
|
# fxc2 build of d3dcompiler_47 - required for Community Shaders shader compilation.
|
||||||
# The winetricks-provided d3dcompiler_47 lacks support for certain shader models
|
# The winetricks-provided d3dcompiler_47 lacks support for certain shader models
|
||||||
# used by Community Shaders, causing "failed shaders" during compilation.
|
# used by Community Shaders, causing "failed shaders" during compilation.
|
||||||
@@ -204,56 +226,6 @@ def _install_dotnet10_desktop_runtime(
|
|||||||
return False
|
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(
|
def _install_fxc2_d3dcompiler(
|
||||||
prefix_path: Path,
|
prefix_path: Path,
|
||||||
log: Callable[[str], None],
|
log: Callable[[str], None],
|
||||||
@@ -360,8 +332,9 @@ def apply_tool_config(
|
|||||||
prefix to Windows 11, which are required for Synthesis. Intentionally opt-in -
|
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
|
the download is ~220MB and the win11 flip has not been verified against NSF/CSF
|
||||||
prefixes (see preserve_global_mscoree).
|
prefixes (see preserve_global_mscoree).
|
||||||
The NuGet Root CA cert (also required for Synthesis) is applied regardless of
|
The NuGet Root CA certs (also required for Synthesis) are only imported when
|
||||||
this flag, since it has no interaction with mscoree hosting or Windows version.
|
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
|
install_fxc2_d3dcompiler=True replaces d3dcompiler_47.dll with the Mozilla
|
||||||
fxc2 build. Only appropriate for Skyrim SE/AE modlists using Community Shaders.
|
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)
|
_install_dotnet10_desktop_runtime(prefix_path, wine_bin, _log)
|
||||||
_set_windows_version_win11(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.
|
# Remove legacy global *mscoree=native from DllOverrides if present.
|
||||||
# Old installs wrote this globally, which breaks .NET 9/10 bootstrap (Synthesis).
|
# Old installs wrote this globally, which breaks .NET 9/10 bootstrap (Synthesis).
|
||||||
# The targeted AppDefaults\SkyrimSE.exe entry written below replaces it.
|
# The targeted AppDefaults\SkyrimSE.exe entry written below replaces it.
|
||||||
@@ -416,7 +383,15 @@ def apply_tool_config(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log(f"Note: could not remove legacy mscoree entry (non-fatal): {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()
|
reg_content = _build_reg_content()
|
||||||
|
regedit_ok = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
@@ -441,23 +416,35 @@ def apply_tool_config(
|
|||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
_log(f"wine regedit exited with code {result.returncode}: {result.stderr[:200]}")
|
_log(f"wine regedit exited with code {result.returncode}: {result.stderr[:200]}")
|
||||||
return False
|
else:
|
||||||
|
|
||||||
_log(f"Tool compatibility settings applied ({len(_XEDIT_EXECUTABLES)} xEdit variants, Pandora, {len(_DLL_OVERRIDES)} DLL overrides)")
|
_log(f"Tool compatibility settings applied ({len(_XEDIT_EXECUTABLES)} xEdit variants, Pandora, {len(_DLL_OVERRIDES)} DLL overrides)")
|
||||||
return True
|
regedit_ok = True
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
_log("wine regedit timed out after 30 seconds")
|
_log("wine regedit timed out after 30 seconds")
|
||||||
return False
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log(f"Failed to apply tool config: {e}")
|
_log(f"Failed to apply tool config: {e}")
|
||||||
return False
|
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
os.unlink(reg_file)
|
os.unlink(reg_file)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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(
|
def setup_nemesis_compatibility(
|
||||||
modlist_dir: str,
|
modlist_dir: str,
|
||||||
|
|||||||
@@ -144,28 +144,7 @@ class ConfigureExistingModlistUIMixin:
|
|||||||
self.resolution_combo = QComboBox()
|
self.resolution_combo = QComboBox()
|
||||||
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
self.resolution_combo.addItem("Leave unchanged")
|
self.resolution_combo.addItem("Leave unchanged")
|
||||||
self.resolution_combo.addItems([
|
self.resolution_combo.addItems(self.resolution_service.get_resolution_list())
|
||||||
"1280x720",
|
|
||||||
"1280x800 (Steam Deck)",
|
|
||||||
"1366x768",
|
|
||||||
"1440x900",
|
|
||||||
"1600x900",
|
|
||||||
"1600x1200",
|
|
||||||
"1680x1050",
|
|
||||||
"1920x1080",
|
|
||||||
"1920x1200",
|
|
||||||
"2048x1152",
|
|
||||||
"2560x1080",
|
|
||||||
"2560x1440",
|
|
||||||
"2560x1600",
|
|
||||||
"3440x1440",
|
|
||||||
"3840x1600",
|
|
||||||
"3840x2160",
|
|
||||||
"3840x2400",
|
|
||||||
"5120x1440",
|
|
||||||
"5120x2160",
|
|
||||||
"7680x4320"
|
|
||||||
])
|
|
||||||
form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
|
form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
|
||||||
form_grid.addWidget(self.resolution_combo, 2, 1)
|
form_grid.addWidget(self.resolution_combo, 2, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -140,28 +140,7 @@ class ConfigureNewModlistUISetupMixin:
|
|||||||
self.resolution_combo = QComboBox()
|
self.resolution_combo = QComboBox()
|
||||||
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
self.resolution_combo.addItem("Leave unchanged")
|
self.resolution_combo.addItem("Leave unchanged")
|
||||||
self.resolution_combo.addItems([
|
self.resolution_combo.addItems(self.resolution_service.get_resolution_list())
|
||||||
"1280x720",
|
|
||||||
"1280x800 (Steam Deck)",
|
|
||||||
"1366x768",
|
|
||||||
"1440x900",
|
|
||||||
"1600x900",
|
|
||||||
"1600x1200",
|
|
||||||
"1680x1050",
|
|
||||||
"1920x1080",
|
|
||||||
"1920x1200",
|
|
||||||
"2048x1152",
|
|
||||||
"2560x1080",
|
|
||||||
"2560x1440",
|
|
||||||
"2560x1600",
|
|
||||||
"3440x1440",
|
|
||||||
"3840x1600",
|
|
||||||
"3840x2160",
|
|
||||||
"3840x2400",
|
|
||||||
"5120x1440",
|
|
||||||
"5120x2160",
|
|
||||||
"7680x4320"
|
|
||||||
])
|
|
||||||
form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
|
form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
|
||||||
|
|
||||||
# Load saved resolution if available
|
# Load saved resolution if available
|
||||||
|
|||||||
@@ -259,28 +259,7 @@ class InstallModlistUISetupMixin:
|
|||||||
self.resolution_combo = QComboBox()
|
self.resolution_combo = QComboBox()
|
||||||
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
self.resolution_combo.addItem("Leave unchanged")
|
self.resolution_combo.addItem("Leave unchanged")
|
||||||
self.resolution_combo.addItems([
|
self.resolution_combo.addItems(self.resolution_service.get_resolution_list())
|
||||||
"1280x720",
|
|
||||||
"1280x800 (Steam Deck)",
|
|
||||||
"1366x768",
|
|
||||||
"1440x900",
|
|
||||||
"1600x900",
|
|
||||||
"1600x1200",
|
|
||||||
"1680x1050",
|
|
||||||
"1920x1080",
|
|
||||||
"1920x1200",
|
|
||||||
"2048x1152",
|
|
||||||
"2560x1080",
|
|
||||||
"2560x1440",
|
|
||||||
"2560x1600",
|
|
||||||
"3440x1440",
|
|
||||||
"3840x1600",
|
|
||||||
"3840x2160",
|
|
||||||
"3840x2400",
|
|
||||||
"5120x1440",
|
|
||||||
"5120x2160",
|
|
||||||
"7680x4320"
|
|
||||||
])
|
|
||||||
# Load saved resolution if available
|
# Load saved resolution if available
|
||||||
saved_resolution = self.resolution_service.get_saved_resolution()
|
saved_resolution = self.resolution_service.get_saved_resolution()
|
||||||
is_steam_deck = False
|
is_steam_deck = False
|
||||||
|
|||||||
@@ -343,10 +343,19 @@ class FileProgressList(QWidget):
|
|||||||
|
|
||||||
def stop_cpu_tracking(self):
|
def stop_cpu_tracking(self):
|
||||||
self._cpu_timer.stop()
|
self._cpu_timer.stop()
|
||||||
if self._cpu_worker and self._cpu_worker.isRunning():
|
worker = self._cpu_worker
|
||||||
self._cpu_worker.quit()
|
if worker is None:
|
||||||
self._cpu_worker.wait(1000)
|
return
|
||||||
self._cpu_worker = None
|
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):
|
def _start_cpu_worker(self):
|
||||||
# Skip if a worker is already running to avoid pileup
|
# Skip if a worker is already running to avoid pileup
|
||||||
|
|||||||
@@ -524,7 +524,7 @@ def check_user_directories(pfx: Path, game_type: str, r: Results):
|
|||||||
if my_games.is_dir():
|
if my_games.is_dir():
|
||||||
r.ok(f"My Games/{MY_GAMES_DIR[game_type]} exists")
|
r.ok(f"My Games/{MY_GAMES_DIR[game_type]} exists")
|
||||||
else:
|
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
|
# AppData/Local
|
||||||
if game_type in APPDATA_LOCAL_DIRS:
|
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.
|
# .NET 9 SDK is installed natively (not via winetricks) for Synthesis.
|
||||||
# NSF/CSF prefixes use global *mscoree=native and deliberately skip dotnet9 (win11 flip
|
# NSF/CSF prefixes use global *mscoree=native and deliberately skip dotnet9 (win11 flip
|
||||||
# breaks NSF), so suppress the check when that override is present.
|
# breaks NSF), so suppress the check when that override is present. Must check the
|
||||||
_nsf_prefix = '"*mscoree"="native"' in content
|
# 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"
|
sdk_base = pfx / "drive_c" / "Program Files" / "dotnet" / "sdk"
|
||||||
if _nsf_prefix:
|
if _nsf_prefix:
|
||||||
r.ok(".NET 9 SDK check skipped (NSF/CSF prefix - global mscoree=native detected)")
|
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:
|
else:
|
||||||
r.warn(".NET 9 SDK not found (drive_c/Program Files/dotnet/sdk/ missing; run Configure Tool Compatibility)")
|
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):
|
def check_steam_artwork(appid: str, r: Results):
|
||||||
"""Check whether Steam grid artwork was applied for this AppID."""
|
"""Check whether Steam grid artwork was applied for this AppID."""
|
||||||
|
|||||||
Reference in New Issue
Block a user