mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 01:07:44 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33b3fbaed2 | ||
|
|
2ff09a1448 | ||
|
|
69fabb32e6 | ||
|
|
6453665620 | ||
|
|
cacbbf1fb1 | ||
|
|
c3551cd269 | ||
|
|
8e4dd06f11 | ||
|
|
e52e1427f6 |
68
CHANGELOG.md
68
CHANGELOG.md
@@ -1,5 +1,73 @@
|
||||
# Jackify Changelog
|
||||
|
||||
## v0.6.0.1 - Hotfix
|
||||
**Release Date:** 24/04/26
|
||||
|
||||
- Resolved some issues with the integration of jackify-engine 0.5.4.
|
||||
|
||||
---
|
||||
|
||||
## v0.6 - Game Support Expansion, Modding Tool Support, Post-Install Quality
|
||||
**Release Date:** 20/04/26
|
||||
|
||||
### New Game Support
|
||||
- Additional Game Support - Post-Install automation for BG3, Skyrim VR, and Fallout 4 VR.
|
||||
- Skyrim VR / Fallout 4 VR: if your modlist needs additional steps you know of, that Jackify does not yet handle, please open an issue on GitHub with your modlist name and the additional steps required. I cannot testing FO4VR directly as I dont own the game.
|
||||
|
||||
### Modding Tool Support
|
||||
- Initial compatibility settings for xEdit, Synthesis, and Pandora are applied automatically during install and configure. Re-apply any time via "Configure Tool Compatibility" in Additional Tasks.
|
||||
|
||||
### Steam Shortcut Graphics
|
||||
- Steam grid artwork now automatically applied to each shortcut, populating all five slots correctly (portrait, landscape, hero, logo, tenfoot).
|
||||
|
||||
### First-Launch Reliability
|
||||
- First Launch Fixes - Skyrim SE modlists should now launch cleanly first time. No more first-launch crash, incorrect AE/CC popup display, initial NXM prompt in MO2, character creation issues, and wrong initial save location.
|
||||
|
||||
### Fixes
|
||||
- Configuration no longer wipes game install paths. Registry writes are now targeted rather than full-prefix replacements.
|
||||
- Fixed crashes on shutdown caused by force-killing background threads.
|
||||
|
||||
### Logging
|
||||
- Console output reduced to errors only. All informational output goes to the log file and Show Details panel.
|
||||
|
||||
### Engine (0.5.4)
|
||||
- Fixed Nexus sessions silently expiring after installs longer than ~1 hour. The engine now persists refreshed OAuth tokens so you stay logged in across long installs.
|
||||
- Fixed large downloads hanging indefinitely if a Nexus CDN connection stalled mid-transfer. Downloads now recover automatically and resume from where they left off.
|
||||
- Removed the disk space pre-flight check, which was incorrectly blocking installs for users with sufficient space. Out-of-disk conditions are still caught and reported if they actually occur.
|
||||
|
||||
---
|
||||
|
||||
## v0.5.0.4 - Hotfix
|
||||
**Release Date:** 29/03/26
|
||||
|
||||
- Fixed self-update failing silently due to the downloaded archive overwriting the extraction target before the update helper could apply it.
|
||||
- Engine updated to 0.5.3. NAME_MAX pre-flight check removed — was incorrectly blocking installs on standard filesystems. eCryptFS/fscrypt users still receive an error at the point of failure.
|
||||
- Fixed Google Drive downloads failing. The Wabbajack CDN proxy was returning a cached broken response for some files; the engine now detects the hash mismatch, retries direct, and constructs a `drive.usercontent.google.com` URL with `confirm=t` to bypass the virus-scan warning page.
|
||||
- Fixed focus stealing from other windows during the Wine component install phase.
|
||||
- Fixed a crash on window close from a leaked focus-reclaim timer.
|
||||
- Baloo file indexer suspended during install and config phases on KDE. No-op elsewhere.
|
||||
- Fixed Flatpak protontricks install failing on fresh Steam Decks due to Flathub not being registered at user scope.
|
||||
|
||||
## v0.5.0.3 - Hotfix
|
||||
**Release Date:** 23/03/26
|
||||
|
||||
- Engine updated to 0.5.2.
|
||||
- Fixed manual downloads getting stuck on "Browser Opened" when the expected filename has a leading numeric prefix (e.g. `1_filename.zip`) that is absent from the browser-saved file. Both the live download watcher and the startup precheck scan now handle this correctly.
|
||||
- Fixed "Continue Anyway" on the disk space warning having no effect. The flag was missing from the CLI argument parser, and a separate engine-level registration bug caused it to be rejected regardless. Both are now resolved. The dialog also correctly displays separate download and install space requirements and notes when both paths share the same drive.
|
||||
- Fixed FNV, FO3, and Enderal modlists losing their game registry paths after configuration. The curated registry files applied during the configuration phase overwrite the Wine prefix registry entirely, wiping the game install paths injected earlier. Jackify now re-injects the correct paths immediately after the curated files are applied.
|
||||
- Improved detection and guidance for modlists that require the Skyrim Special Edition Creation Kit. If the engine reports missing Creation Kit files, Jackify now surfaces step-by-step instructions for installing and first-launching the Creation Kit via Steam so the required files are in place before retrying.
|
||||
- Filesystem filename length limit (NAME_MAX) no longer hard-blocks installation on standard filesystems. The check previously triggered incorrectly on ext4/btrfs/XFS. For users on encrypted home directories where the limit is genuinely reduced, Jackify now shows a warning dialog listing the affected files with a "Continue Anyway" option.
|
||||
- Archive index errors now produce an actionable failure message identifying the specific archive to delete and re-download, rather than a bare engine exception.
|
||||
- TTW installer temporary working files are now cleaned up after each TTW installation run. These files were previously never removed and could accumulate several GB per install attempt.
|
||||
- Each GitHub release now includes a `SHA256SUMS` file for verifying your download. See the README for instructions.
|
||||
|
||||
## v0.5.0.2 - Hotfix
|
||||
**Release Date:** 15/03/26
|
||||
|
||||
- Disk space warning at install start is no longer a hard block. If the pre-flight check fires before any download or install progress has started, Jackify now shows a warning dialog with the required and available space, a note that modlist updates typically need far less space than a fresh install, and a "Continue Anyway" option. Cancelling still aborts normally.
|
||||
- Engine: fixed a false-positive in the pre-flight filename length check that could incorrectly trigger on modlist paths using backslash separators.
|
||||
- Engine: temp folder cleanup at the end of install no longer crashes an otherwise successful installation if a BSA or temp directory is still locked.
|
||||
|
||||
## v0.5.0.1 - Hotfix
|
||||
**Release Date:** 13/03/26
|
||||
|
||||
|
||||
15
README.md
15
README.md
@@ -35,16 +35,15 @@ Jackify is a Linux application for installing and configuring Wabbajack modlists
|
||||
## Requirements
|
||||
|
||||
- Linux system (most modern distributions will work)
|
||||
- Steam installed and configured
|
||||
- Steam installed and configured — **the Snap version of Steam is not supported**
|
||||
- **Protontricks** — required for modlist configuration
|
||||
- See [Installing Additional Tools](https://github.com/Omni-guides/Jackify/wiki/Installing-Additional-Tools#installing-protontricks)
|
||||
- **GE-Proton 10-14** — While other Proton versions may work, GE-Proton 10-14 is highly recommended for ENB compatibility
|
||||
- See [Installing Additional Tools](https://github.com/Omni-guides/Jackify/wiki/Installing-Additional-Tools#installing-ge-proton)
|
||||
- **Nexus Mods account** (Premium required for automated downloads)
|
||||
- Non-Premium accounts are supported, but some downloads may require manual browser steps
|
||||
- **Nexus Mods account** (Premium required for fully automated downloads; Non-Premium supported with manual browser steps)
|
||||
- See the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide) for full details on the options available
|
||||
- **FUSE2 compatibility (libfuse.so.2) is required for AppImage execution**
|
||||
- **Ubuntu/Debian-based distros only** (Ubuntu, Kubuntu, Linux Mint, Pop!_OS, Zorin OS, elementary OS, and others): Qt platform plugin library
|
||||
- **IF YOU ARE USING an Ubuntu/Debian-based distro** (Ubuntu, Kubuntu, Linux Mint, Pop!_OS, Zorin OS, elementary OS, and others): Qt platform plugin library
|
||||
- `sudo apt install libxcb-cursor-dev`
|
||||
|
||||
## Installation Quick Start
|
||||
@@ -60,6 +59,14 @@ chmod +x Jackify.AppImage
|
||||
|
||||
For CLI mode: `./Jackify.AppImage --cli`
|
||||
|
||||
To verify your download, each release includes a `SHA256SUMS` file on the [GitHub releases page](https://github.com/Omni-guides/Jackify/releases/latest). Download it into the same folder as the AppImage, then run:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS
|
||||
```
|
||||
|
||||
You should see `Jackify.AppImage: OK`. If you see a failure, do not run the file.
|
||||
|
||||
For a full step-by-step guide with screenshots, see the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide).
|
||||
|
||||
## Supported Games
|
||||
|
||||
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
||||
Wabbajack modlists natively on Linux systems.
|
||||
"""
|
||||
|
||||
__version__ = "0.5.0.1"
|
||||
__version__ = "0.6.0.1"
|
||||
|
||||
@@ -121,14 +121,16 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
if debug_mode:
|
||||
cmd.append('--debug')
|
||||
self.logger.info("Adding --debug flag to jackify-engine")
|
||||
|
||||
writeback_path = str(auth_service.get_token_writeback_path())
|
||||
original_env_values = {
|
||||
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
|
||||
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
|
||||
'JACKIFY_TOKEN_WRITEBACK': os.environ.get('JACKIFY_TOKEN_WRITEBACK'),
|
||||
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
|
||||
}
|
||||
|
||||
try:
|
||||
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
|
||||
if oauth_info:
|
||||
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
|
||||
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
|
||||
@@ -280,6 +282,7 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
|
||||
proc.wait()
|
||||
self._current_process = None
|
||||
auth_service.apply_token_writeback(writeback_path)
|
||||
if proc.returncode != 0:
|
||||
print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}")
|
||||
self.logger.error(f"Engine exited with code {proc.returncode}.")
|
||||
@@ -592,6 +595,36 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
if update_existing_install and app_id:
|
||||
print(f"{COLOR_SUCCESS}Update mode Steam setup confirmed.{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}Reusing Steam AppID: {app_id}{COLOR_RESET}")
|
||||
# Apply artwork and restart Steam -- skipped in update path since the full
|
||||
# workflow is bypassed, but artwork and Steam state still need refreshing.
|
||||
_game_type = self.context.get('detected_game') or self.context.get('special_game_type')
|
||||
try:
|
||||
from jackify.backend.handlers.modlist_handler import ModlistHandler
|
||||
ModlistHandler().set_steam_grid_images(str(app_id), install_dir_str, game_type=_game_type)
|
||||
except Exception as e:
|
||||
self.logger.warning("Failed to apply Steam artwork in update mode: %s", e)
|
||||
if _game_type == 'cp2077':
|
||||
# CP2077 launch options may be absent on lists originally installed
|
||||
# under v0.5 before CP2077 support was added.
|
||||
try:
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
sh = ShortcutHandler(
|
||||
config_handler=ConfigHandler(),
|
||||
steamdeck=bool(self.system_info and self.system_info.is_steamdeck),
|
||||
)
|
||||
sh.update_shortcut_launch_options(
|
||||
shortcut_name,
|
||||
mo2_exe_path,
|
||||
'WINEDLLOVERRIDES="version=n,b;winmm=n,b" %command%',
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning("Failed to update CP2077 launch options in update mode: %s", e)
|
||||
try:
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
AutomatedPrefixService(self.system_info).restart_steam()
|
||||
except Exception as e:
|
||||
self.logger.warning("Failed to restart Steam in update mode: %s", e)
|
||||
else:
|
||||
print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}")
|
||||
if prefix_path:
|
||||
|
||||
@@ -70,7 +70,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
"manual_download_concurrent_limit": 2, # Shared GUI/CLI default for manual download browser tabs
|
||||
"manual_download_watch_directory": None, # Optional override for manual-download watcher folder
|
||||
"window_width": None, # Saved window width (None = use dynamic sizing)
|
||||
"window_height": None # Saved window height (None = use dynamic sizing)
|
||||
"window_height": None, # Saved window height (None = use dynamic sizing)
|
||||
}
|
||||
|
||||
# Load configuration if exists
|
||||
|
||||
@@ -521,11 +521,13 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
|
||||
# Game-specific Documents directory names (for both Linux home and Wine prefix)
|
||||
game_docs_dirs = {
|
||||
"skyrimse": "Skyrim Special Edition",
|
||||
"skyrimvr": "Skyrim VR",
|
||||
"fallout4": "Fallout4",
|
||||
"fallout4vr": "Fallout4VR",
|
||||
"falloutnv": "FalloutNV",
|
||||
"oblivion": "Oblivion",
|
||||
"enderal": "Enderal Special Edition",
|
||||
"enderalse": "Enderal Special Edition"
|
||||
"enderalse": "Enderal Special Edition",
|
||||
}
|
||||
|
||||
game_dirs = {
|
||||
@@ -561,41 +563,193 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
self.logger.debug(f"Created game-specific directory: {dir_path}")
|
||||
|
||||
# CRITICAL: Create game-specific Documents directories in Wine prefix
|
||||
# CP2077 and BG3 use AppData/Local only (no My Games)
|
||||
appdata_only_dirs = {
|
||||
"cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"),
|
||||
"bg3": os.path.join("Larian Studios", "Baldur's Gate 3"),
|
||||
}
|
||||
|
||||
# CRITICAL: Create game-specific directories in Wine prefix
|
||||
# Required for USVFS to virtualize profile INIs on first launch
|
||||
if game_name in game_docs_dirs:
|
||||
docs_dir_name = game_docs_dirs[game_name]
|
||||
|
||||
# Find compatdata path for this AppID
|
||||
from ..handlers.path_handler import PathHandler
|
||||
path_handler = PathHandler()
|
||||
compatdata_path = path_handler.find_compat_data(appid)
|
||||
|
||||
if compatdata_path:
|
||||
# Create Documents/My Games/{GameName} in Wine prefix
|
||||
wine_docs_path = os.path.join(
|
||||
str(compatdata_path),
|
||||
"pfx",
|
||||
"drive_c",
|
||||
"users",
|
||||
"steamuser",
|
||||
"Documents",
|
||||
"My Games",
|
||||
docs_dir_name
|
||||
from ..handlers.path_handler import PathHandler
|
||||
path_handler = PathHandler()
|
||||
compatdata_path = path_handler.find_compat_data(appid)
|
||||
|
||||
if compatdata_path:
|
||||
prefix_user = os.path.join(
|
||||
str(compatdata_path), "pfx", "drive_c", "users", "steamuser"
|
||||
)
|
||||
|
||||
if game_name in appdata_only_dirs:
|
||||
appdata_path = os.path.join(
|
||||
prefix_user, "AppData", "Local", appdata_only_dirs[game_name]
|
||||
)
|
||||
try:
|
||||
os.makedirs(appdata_path, exist_ok=True)
|
||||
self.logger.info(f"Created Wine prefix AppData/Local directory: {appdata_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create AppData/Local directory {appdata_path}: {e}")
|
||||
|
||||
elif game_name in game_docs_dirs:
|
||||
docs_dir_name = game_docs_dirs[game_name]
|
||||
wine_docs_path = os.path.join(
|
||||
prefix_user, "Documents", "My Games", docs_dir_name
|
||||
)
|
||||
|
||||
try:
|
||||
os.makedirs(wine_docs_path, exist_ok=True)
|
||||
self.logger.info(f"Created Wine prefix Documents directory for USVFS: {wine_docs_path}")
|
||||
self.logger.debug(f"This allows USVFS to virtualize profile INI files on first launch")
|
||||
self.logger.info(f"Created Wine prefix Documents directory: {wine_docs_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Wine prefix Documents directory {wine_docs_path}: {e}")
|
||||
# Don't fail completely - this is a first-launch optimization
|
||||
else:
|
||||
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix Documents directory creation")
|
||||
self.logger.debug("Wine prefix Documents directories will be created when game runs for first time")
|
||||
|
||||
if game_name == "skyrimse":
|
||||
self._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
|
||||
elif game_name == "fallout4":
|
||||
self._seed_fo4_first_launch_files(prefix_user, docs_dir_name)
|
||||
elif game_name == "skyrimvr":
|
||||
self._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
|
||||
elif game_name == "fallout4vr":
|
||||
self._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
|
||||
else:
|
||||
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix directory creation")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating required directories: {e}")
|
||||
return False
|
||||
|
||||
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""
|
||||
Pre-seed files in the Wine prefix that Skyrim SE/AE needs on first launch.
|
||||
|
||||
Two files must exist before first launch to avoid USVFS and engine issues:
|
||||
|
||||
1. AppData/Local/Skyrim Special Edition/Plugins.txt - empty anchor file.
|
||||
USVFS builds its VFS tree at MO2 startup. If this path does not exist,
|
||||
USVFS logs the directory as missing and skips adding Plugins.txt to the
|
||||
initial tree. It then tries to reroute the file dynamically, but a mutex
|
||||
deadlock (thread never releases the write mutex on first launch) blocks
|
||||
the reroute. The game falls through to the real filesystem, finds no
|
||||
Plugins.txt, and loads only base-game ESPs - causing a null form crash
|
||||
for any SKSE plugin that expects modlist ESPs (e.g. BladeAndBlunt.dll).
|
||||
On second launch the directory exists, USVFS initialises correctly, no crash.
|
||||
Pre-seeding an empty file gives USVFS its anchor; content is irrelevant
|
||||
because USVFS reroutes reads to the active MO2 profile's plugins.txt anyway.
|
||||
|
||||
2. Documents/My Games/Skyrim Special Edition/SkyrimPrefs.ini - minimal stub.
|
||||
The CC/AE download prompt is triggered by bDownloadCC=0 (or absent) in
|
||||
SkyrimPrefs.ini. This check fires before PrivateProfileRedirector (PPR)
|
||||
hooks the Windows INI API, so the game reads the real prefix path directly,
|
||||
not the MO2 profile version. A minimal stub with bDownloadCC=1 suppresses
|
||||
the prompt. PPR redirects all subsequent reads to the active profile once
|
||||
it loads, so this stub is never read again after early engine init.
|
||||
Only created if the file does not already exist.
|
||||
"""
|
||||
# Fix 1: empty Plugins.txt anchor for USVFS
|
||||
appdata_sse = os.path.join(prefix_user, "AppData", "Local", "Skyrim Special Edition")
|
||||
plugins_txt = os.path.join(appdata_sse, "Plugins.txt")
|
||||
try:
|
||||
os.makedirs(appdata_sse, exist_ok=True)
|
||||
if not os.path.exists(plugins_txt):
|
||||
open(plugins_txt, 'w').close()
|
||||
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
|
||||
else:
|
||||
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
|
||||
|
||||
# Fix 2: minimal SkyrimPrefs.ini at real Documents path to suppress AE popup
|
||||
skyrimprefs_path = os.path.join(
|
||||
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
|
||||
)
|
||||
try:
|
||||
if not os.path.exists(skyrimprefs_path):
|
||||
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
|
||||
f.write("[General]\nbDownloadCC=1\n")
|
||||
self.logger.info(f"Created SkyrimPrefs.ini stub to suppress AE popup: {skyrimprefs_path}")
|
||||
else:
|
||||
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
|
||||
|
||||
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""
|
||||
Pre-seed files in the Wine prefix that Fallout 4 needs on first launch.
|
||||
|
||||
1. AppData/Local/Fallout4/Plugins.txt - empty anchor file for USVFS.
|
||||
Same mutex deadlock mechanism as Skyrim SE - confirmed to apply to FO4.
|
||||
|
||||
INI stub for CC popup suppression is intentionally omitted until the correct
|
||||
key name in Fallout4Prefs.ini is confirmed via testing.
|
||||
"""
|
||||
appdata_fo4 = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
|
||||
plugins_txt = os.path.join(appdata_fo4, "Plugins.txt")
|
||||
try:
|
||||
os.makedirs(appdata_fo4, exist_ok=True)
|
||||
if not os.path.exists(plugins_txt):
|
||||
open(plugins_txt, 'w').close()
|
||||
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
|
||||
else:
|
||||
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
|
||||
|
||||
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""
|
||||
Pre-seed files in the Wine prefix that Skyrim VR needs on first launch.
|
||||
|
||||
1. AppData/Local/Skyrim VR/Plugins.txt - empty anchor file for USVFS.
|
||||
Same mutex deadlock mechanism as Skyrim SE applies to VR.
|
||||
|
||||
2. Documents/My Games/Skyrim VR/SkyrimPrefs.ini - minimal stub with two keys:
|
||||
- bDownloadCC=1: suppresses the AE/CC download prompt (same engine behaviour
|
||||
as Skyrim SE; fires before PPR hooks the INI API).
|
||||
- bLoadVRPlayroom=0: prevents the game loading the Bethesda VR playroom
|
||||
tutorial on first launch. Without this, SkyrimVR skips the main menu and
|
||||
drops the user into the playroom, bypassing the modlist's startup sequence.
|
||||
"""
|
||||
appdata_vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
|
||||
plugins_txt = os.path.join(appdata_vr, "Plugins.txt")
|
||||
try:
|
||||
os.makedirs(appdata_vr, exist_ok=True)
|
||||
if not os.path.exists(plugins_txt):
|
||||
open(plugins_txt, 'w').close()
|
||||
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
|
||||
else:
|
||||
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
|
||||
|
||||
skyrimprefs_path = os.path.join(
|
||||
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
|
||||
)
|
||||
try:
|
||||
if not os.path.exists(skyrimprefs_path):
|
||||
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
|
||||
f.write("[General]\nbDownloadCC=1\nbLoadVRPlayroom=0\n")
|
||||
self.logger.info(f"Created SkyrimPrefs.ini stub for VR first-launch: {skyrimprefs_path}")
|
||||
else:
|
||||
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
|
||||
|
||||
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""
|
||||
Pre-seed files in the Wine prefix that Fallout 4 VR needs on first launch.
|
||||
|
||||
1. AppData/Local/Fallout4VR/Plugins.txt - empty anchor file for USVFS.
|
||||
Same mutex deadlock mechanism as Skyrim SE and FO4 applies to VR.
|
||||
|
||||
INI stub is intentionally omitted - the correct key name in Fallout4VRPrefs.ini
|
||||
has not been confirmed via testing.
|
||||
"""
|
||||
appdata_fo4vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
|
||||
plugins_txt = os.path.join(appdata_fo4vr, "Plugins.txt")
|
||||
try:
|
||||
os.makedirs(appdata_fo4vr, exist_ok=True)
|
||||
if not os.path.exists(plugins_txt):
|
||||
open(plugins_txt, 'w').close()
|
||||
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
|
||||
else:
|
||||
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
|
||||
|
||||
@@ -64,7 +64,7 @@ class FilesystemSteamMixin:
|
||||
|
||||
default_path = Path.home() / ".steam/steam/steamapps/common"
|
||||
if default_path.is_dir():
|
||||
logger.warning(f"Using default Steam library path: {default_path}")
|
||||
logger.info(f"Using default Steam library path: {default_path}")
|
||||
return default_path
|
||||
|
||||
logger.error("No valid Steam library found via vdf or at default location.")
|
||||
|
||||
@@ -18,7 +18,11 @@ class GameDetector:
|
||||
'fallout3': ['Fallout 3'],
|
||||
'oblivion': ['Oblivion'],
|
||||
'starfield': ['Starfield'],
|
||||
'oblivion_remastered': ['Oblivion Remastered']
|
||||
'oblivion_remastered': ['Oblivion Remastered'],
|
||||
'skyrimvr': ['Skyrim VR'],
|
||||
'fallout4vr': ['Fallout 4 VR'],
|
||||
'cp2077': ['Cyberpunk 2077'],
|
||||
'bg3': ["Baldur's Gate 3"],
|
||||
}
|
||||
|
||||
def detect_game_type(self, modlist_name: str) -> Optional[str]:
|
||||
@@ -26,9 +30,17 @@ class GameDetector:
|
||||
modlist_lower = modlist_name.lower()
|
||||
|
||||
# Check for game-specific keywords in modlist name
|
||||
# Check for Oblivion Remastered first since "oblivion" is a substring
|
||||
# Check more specific types before their generic parents
|
||||
if any(keyword in modlist_lower for keyword in ['oblivion remastered', 'oblivionremastered', 'oblivion_remastered']):
|
||||
return 'oblivion_remastered'
|
||||
elif any(keyword in modlist_lower for keyword in ['skyrim vr', 'skyrimvr']):
|
||||
return 'skyrimvr'
|
||||
elif any(keyword in modlist_lower for keyword in ['fallout 4 vr', 'fallout4vr', 'fo4vr']):
|
||||
return 'fallout4vr'
|
||||
elif any(keyword in modlist_lower for keyword in ['cyberpunk', 'cp2077', 'cyberpunk 2077']):
|
||||
return 'cp2077'
|
||||
elif any(keyword in modlist_lower for keyword in ["baldur's gate 3", 'baldursgate3', 'bg3']):
|
||||
return 'bg3'
|
||||
elif any(keyword in modlist_lower for keyword in ['skyrim', 'sse', 'skse', 'dragonborn', 'dawnguard']):
|
||||
return 'skyrim'
|
||||
elif any(keyword in modlist_lower for keyword in ['fallout 4', 'fo4', 'f4se', 'commonwealth']):
|
||||
@@ -134,9 +146,37 @@ class GameDetector:
|
||||
'min_proton_version': '8.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks']
|
||||
}
|
||||
},
|
||||
'skyrimvr': {
|
||||
'launcher': 'SKSE',
|
||||
'min_proton_version': '6.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks'],
|
||||
'notes': 'SteamVR must be installed separately',
|
||||
},
|
||||
'fallout4vr': {
|
||||
'launcher': 'F4SE',
|
||||
'min_proton_version': '6.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks'],
|
||||
'notes': 'SteamVR must be installed separately',
|
||||
},
|
||||
'cp2077': {
|
||||
'launcher': 'redmod',
|
||||
'min_proton_version': '8.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks'],
|
||||
'notes': 'Requires WINEDLLOVERRIDES=version=n,b;winmm=n,b for Red4ext/CET. Rootbuilder must use COPY mode.',
|
||||
},
|
||||
'bg3': {
|
||||
'launcher': 'bg3_dx11',
|
||||
'min_proton_version': '8.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks'],
|
||||
'notes': 'Rootbuilder must use COPY mode.',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
return requirements.get(game_type, {})
|
||||
|
||||
def detect_mods(self, modlist_path: Path) -> List[Dict]:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import logging
|
||||
import requests
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
@@ -147,33 +146,14 @@ class ModlistConfigurationMixin:
|
||||
print("───────────────────────────────────────────────────────────────────")
|
||||
input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}")
|
||||
self.logger.info("User confirmed completion of manual steps.")
|
||||
# Step 3: Download and apply curated user.reg.modlist and system.reg.modlist
|
||||
# Step 3: Apply targeted registry tweaks (replaces wholesale curated reg file overwrite)
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Applying curated registry files for modlist configuration")
|
||||
self.logger.info("Step 3: Downloading and applying curated user.reg.modlist and system.reg.modlist...")
|
||||
status_callback(f"{self._get_progress_timestamp()} Applying modlist registry configuration")
|
||||
self.logger.info("Step 3: Applying modlist registry tweaks...")
|
||||
try:
|
||||
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
|
||||
if not prefix_path_str or not os.path.isdir(prefix_path_str):
|
||||
raise Exception("Could not determine Wine prefix path for this modlist. Please ensure you have launched the shortcut from Steam at least once.")
|
||||
user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.modlist"
|
||||
user_reg_dest = Path(prefix_path_str) / "user.reg"
|
||||
response = requests.get(user_reg_url, verify=True)
|
||||
response.raise_for_status()
|
||||
with open(user_reg_dest, "wb") as f:
|
||||
f.write(response.content)
|
||||
self.logger.info(f"Curated user.reg.modlist downloaded and applied to {user_reg_dest}")
|
||||
system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.modlist"
|
||||
system_reg_dest = Path(prefix_path_str) / "system.reg"
|
||||
response = requests.get(system_reg_url, verify=True)
|
||||
response.raise_for_status()
|
||||
with open(system_reg_dest, "wb") as f:
|
||||
f.write(response.content)
|
||||
self.logger.info(f"Curated system.reg.modlist downloaded and applied to {system_reg_dest}")
|
||||
self._apply_modlist_registry_tweaks()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}")
|
||||
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}")
|
||||
return False
|
||||
self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.")
|
||||
self.logger.warning("Modlist registry tweaks failed (non-fatal): %s", e)
|
||||
|
||||
# Step 4: Install Wine Components
|
||||
if status_callback:
|
||||
@@ -239,18 +219,12 @@ class ModlistConfigurationMixin:
|
||||
status_callback(f"{self._get_progress_timestamp()} {failure_msg}")
|
||||
# Continue but user should be aware of potential issues
|
||||
|
||||
# Step 4.6: Enable dotfiles visibility for Wine prefix
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Enabling dotfiles visibility")
|
||||
self.logger.info("Step 4.6: Enabling dotfiles visibility in Wine prefix...")
|
||||
# Step 4.6: Audit final registry state - confirms all writes survived winetricks
|
||||
self.logger.info("Step 4.6: Auditing registry state...")
|
||||
try:
|
||||
if self.protontricks_handler.enable_dotfiles(self.appid):
|
||||
self.logger.info("Dotfiles visibility enabled successfully")
|
||||
else:
|
||||
self.logger.warning("Failed to enable dotfiles visibility (non-critical, continuing)")
|
||||
self._audit_registry_state()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error enabling dotfiles visibility: {e} (non-critical, continuing)")
|
||||
self.logger.info("Step 4.6: Enabling dotfiles visibility... Done")
|
||||
self.logger.warning("Registry audit failed (non-fatal): %s", e)
|
||||
|
||||
# Step 4.7: Create Wine prefix Documents directories for USVFS
|
||||
# Critical for USVFS profile INI virtualization on first launch
|
||||
@@ -258,17 +232,40 @@ class ModlistConfigurationMixin:
|
||||
status_callback(f"{self._get_progress_timestamp()} Creating Wine prefix Documents directories for USVFS")
|
||||
self.logger.info("Step 4.7: Creating Wine prefix Documents directories for USVFS...")
|
||||
try:
|
||||
if self.appid and self.game_var:
|
||||
# Map game_var to game_name for create_required_dirs
|
||||
if self.appid:
|
||||
# Map detected game type to the key expected by create_required_dirs
|
||||
game_name_map = {
|
||||
"skyrim": "skyrimse",
|
||||
"skyrimspecialedition": "skyrimse",
|
||||
"skyrimvr": "skyrimvr",
|
||||
"fallout": "fallout4",
|
||||
"fallout4": "fallout4",
|
||||
"fo4": "fallout4",
|
||||
"fallout4vr": "fallout4vr",
|
||||
"fnv": "falloutnv",
|
||||
"falloutnv": "falloutnv",
|
||||
"oblivion": "oblivion",
|
||||
"enderalspecialedition": "enderalse"
|
||||
"enderal": "enderalse",
|
||||
"enderalspecialedition": "enderalse",
|
||||
"bg3": "bg3",
|
||||
"baldursgate3": "bg3",
|
||||
"cp2077": "cp2077",
|
||||
"starfield": "starfield",
|
||||
}
|
||||
game_name = game_name_map.get(self.game_var.lower(), None)
|
||||
|
||||
game_name = game_name_map.get((self.game_var or '').lower(), None)
|
||||
|
||||
# Fallback: read gameName= directly from ModOrganizer.ini when loader-based
|
||||
# detection returned Unknown (e.g. Enderal uses a non-SKSE launcher variant)
|
||||
if not game_name and self.modlist_dir:
|
||||
try:
|
||||
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
|
||||
_detected = detect_game_type_from_modlist(str(self.modlist_dir))
|
||||
if _detected:
|
||||
game_name = game_name_map.get(_detected, _detected)
|
||||
self.logger.info(f"Step 4.7: game type resolved via gameName= fallback: {_detected} -> {game_name}")
|
||||
except Exception as _fe:
|
||||
self.logger.debug(f"Step 4.7 fallback detection failed: {_fe}")
|
||||
|
||||
if game_name:
|
||||
appid_str = str(self.appid)
|
||||
if self.filesystem_handler.create_required_dirs(game_name, appid_str):
|
||||
@@ -276,13 +273,42 @@ class ModlistConfigurationMixin:
|
||||
else:
|
||||
self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)")
|
||||
else:
|
||||
self.logger.debug(f"Game {self.game_var} not in directory creation map, skipping")
|
||||
self.logger.debug(f"Game {self.game_var!r} not in directory creation map, skipping")
|
||||
else:
|
||||
self.logger.warning("AppID or game_var not available, skipping Wine prefix Documents directory creation")
|
||||
self.logger.warning("AppID not available, skipping Wine prefix Documents directory creation")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)")
|
||||
self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done")
|
||||
|
||||
# Step 4.8: Configure nxmhandler.ini to suppress MO2 NXM registration popup
|
||||
self.logger.info("Step 4.8: Configuring nxmhandler.ini...")
|
||||
try:
|
||||
self._configure_nxmhandler_ini()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"nxmhandler.ini configuration failed (non-critical): {e}")
|
||||
self.logger.info("Step 4.8: Configuring nxmhandler.ini... Done")
|
||||
|
||||
# Step 4.9: Inject game install path registry entries (FNV/FO3/Enderal/CP2077/BG3).
|
||||
# Required so the game launcher and engine can locate the base game when
|
||||
# MO2 is running inside the Proton prefix. Idempotent: safe to run on
|
||||
# reinstall or re-configure.
|
||||
self.logger.info("Step 4.9: Injecting game registry entries...")
|
||||
try:
|
||||
compatdata_path = self.path_handler.find_compat_data(str(self.appid))
|
||||
if compatdata_path:
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
_svc = AutomatedPrefixService(SystemInfo(
|
||||
is_steamdeck=PlatformDetectionService.get_instance().is_steamdeck
|
||||
))
|
||||
_svc._inject_game_registry_entries(str(compatdata_path), self.game_var or '')
|
||||
else:
|
||||
self.logger.debug("Compatdata path not found for game registry injection, skipping")
|
||||
except Exception as e:
|
||||
self.logger.warning("Game registry injection failed (non-fatal): %s", e)
|
||||
self.logger.info("Step 4.9: Injecting game registry entries... Done")
|
||||
|
||||
# Step 5: Verify ownership of Modlist directory
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership")
|
||||
@@ -309,6 +335,10 @@ class ModlistConfigurationMixin:
|
||||
self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}")
|
||||
self.logger.info("Step 6: Backing up ModOrganizer.ini... Done")
|
||||
|
||||
# Step 6.1: BG3-specific patches to ModOrganizer.ini and MO2 plugins
|
||||
self._patch_bg3_mod_settings_plugin()
|
||||
self._set_bg3_rootbuilder_copy_mode()
|
||||
|
||||
# Step 6.5: Handle symlinked downloads directory
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory")
|
||||
@@ -400,6 +430,21 @@ class ModlistConfigurationMixin:
|
||||
self.logger.info("Set download_directory in ModOrganizer.ini (Install flow)")
|
||||
else:
|
||||
self.logger.warning("Could not set download_directory in ModOrganizer.ini")
|
||||
elif modlist_ini_path_obj.is_file():
|
||||
# Configure Existing / Configure New flows: no explicit download_dir is set, but the
|
||||
# INI may have duplicate or mangled entries from the original Wabbajack install.
|
||||
# Read the first valid value, then re-write all occurrences to that value so MO2
|
||||
# reads the correct path regardless of which occurrence it picks up last.
|
||||
existing_linux = self.path_handler.get_download_directory_linux_path(modlist_ini_path_obj)
|
||||
if existing_linux:
|
||||
if self.path_handler.set_download_directory(
|
||||
modlist_ini_path_obj, existing_linux, self.modlist_sdcard
|
||||
):
|
||||
self.logger.info("Normalised download_directory entries in ModOrganizer.ini")
|
||||
else:
|
||||
self.logger.warning("Could not normalise download_directory in ModOrganizer.ini")
|
||||
else:
|
||||
self.logger.debug("No existing download_directory value found in ModOrganizer.ini; skipping normalisation")
|
||||
|
||||
# Step 8.5: Align /home vs /var/home basis for Z: paths to match modlist install directory.
|
||||
# This is intentionally separate from broad binary-path rewriting so it still runs when
|
||||
@@ -565,6 +610,47 @@ class ModlistConfigurationMixin:
|
||||
status_callback(f"{self._get_progress_timestamp()} Re-applying final Windows compatibility settings")
|
||||
self._re_enforce_windows_10_mode()
|
||||
|
||||
# Step 15: Apply tool compatibility settings (xEdit, Pandora, DLL overrides).
|
||||
# Only runs for standard Skyrim SE/AE modlists. Non-Skyrim games (Enderal, FNV,
|
||||
# FO3, etc.) are excluded because the mscoree AppDefault targets SkyrimSE.exe,
|
||||
# which is also Enderal's executable, causing a crash on those modlists.
|
||||
_special_type = self.detect_special_game_type(self.modlist_dir)
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
if ConfigHandler().get('auto_tool_compat', True) and _special_type is None:
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Applying tool compatibility settings")
|
||||
self.logger.info("Step 15: Applying tool compatibility settings...")
|
||||
compatdata_path = str(wineprefix).replace("/pfx", "").rstrip("/")
|
||||
wine_bin = self._find_wine_binary_for_registry()
|
||||
if compatdata_path and wine_bin:
|
||||
from jackify.backend.services.tool_config_service import apply_tool_config
|
||||
apply_tool_config(
|
||||
compatdata_path,
|
||||
wine_bin,
|
||||
log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None,
|
||||
install_dotnet9_sdk=True,
|
||||
install_fxc2_d3dcompiler=True,
|
||||
)
|
||||
self.logger.info("Step 15: Tool compatibility settings applied")
|
||||
else:
|
||||
self.logger.warning("Step 15: Could not resolve prefix path or wine binary - skipping tool compat")
|
||||
elif _special_type is not None:
|
||||
self.logger.info(f"Step 15: Skipping tool compat for {_special_type} modlist")
|
||||
except Exception as e:
|
||||
self.logger.warning("Step 15: Tool compatibility settings failed (non-fatal): %s", e)
|
||||
|
||||
# Step 16: Nemesis compatibility setup (symlink + workingDirectory fix)
|
||||
try:
|
||||
from jackify.backend.services.tool_config_service import setup_nemesis_compatibility
|
||||
setup_nemesis_compatibility(
|
||||
modlist_dir=self.modlist_dir,
|
||||
stock_game_path=self.stock_game_path,
|
||||
log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning("Step 16: Nemesis setup failed (non-fatal): %s", e)
|
||||
|
||||
return True # Return True on success
|
||||
|
||||
def run_modlist_configuration_phase(self, context: dict = None) -> bool:
|
||||
@@ -578,6 +664,48 @@ class ModlistConfigurationMixin:
|
||||
status_callback = context.get('status_callback') if context else None
|
||||
return self._execute_configuration_steps(status_callback=status_callback)
|
||||
|
||||
def _configure_nxmhandler_ini(self) -> None:
|
||||
"""
|
||||
Set noregister=true in nxmhandler.ini in the MO2 install directory.
|
||||
|
||||
MO2 reads this flag on startup and skips the NXM handler registration
|
||||
popup when it is true. On Linux, MO2's NXM handler cannot be registered
|
||||
usefully via Wine; Jackify will become its own NXM handler in a later cycle.
|
||||
Safe to apply on every configuration run - always correct on Linux.
|
||||
"""
|
||||
if not self.modlist_dir:
|
||||
return
|
||||
|
||||
nxm_ini_path = os.path.join(self.modlist_dir, "nxmhandler.ini")
|
||||
|
||||
try:
|
||||
if os.path.exists(nxm_ini_path):
|
||||
with open(nxm_ini_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
if re.search(r'(?im)^\s*noregister\s*=\s*true\s*$', content):
|
||||
self.logger.debug("nxmhandler.ini noregister already true, skipping")
|
||||
return
|
||||
|
||||
# Replace existing noregister=... line if present, otherwise inject after [General]
|
||||
if re.search(r'(?im)^\s*noregister\s*=', content):
|
||||
content = re.sub(r'(?im)^\s*noregister\s*=.*$', 'noregister=true', content)
|
||||
elif re.search(r'(?im)^\s*\[General\]', content):
|
||||
content = re.sub(r'(?im)(^\s*\[General\]\s*\n)', r'\1noregister=true\n', content)
|
||||
else:
|
||||
content += '\n[General]\nnoregister=true\n'
|
||||
|
||||
with open(nxm_ini_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
self.logger.info(f"Set noregister=true in {nxm_ini_path}")
|
||||
else:
|
||||
# MO2 creates nxmhandler.ini on first run; pre-create with the flag set
|
||||
with open(nxm_ini_path, 'w', encoding='utf-8') as f:
|
||||
f.write("[General]\nnoregister=true\n")
|
||||
self.logger.info(f"Created nxmhandler.ini with noregister=true: {nxm_ini_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not configure nxmhandler.ini: {e}")
|
||||
|
||||
def _prompt_or_set_resolution(self):
|
||||
# If on Steam Deck, set 1280x800 automatically
|
||||
if self._is_steam_deck():
|
||||
@@ -598,3 +726,73 @@ class ModlistConfigurationMixin:
|
||||
else:
|
||||
self.selected_resolution = None
|
||||
self.logger.info("Resolution setup skipped by user.")
|
||||
|
||||
def _patch_bg3_mod_settings_plugin(self) -> None:
|
||||
"""
|
||||
Fix a bug in the BG3 MO2 plugin (Alvadus/BG3-MO2-Unofficial-Plugin) where
|
||||
mods_order_node is conditionally created but unconditionally referenced.
|
||||
Bug present in upstream source as of 2026-03; author not yet notified.
|
||||
Safe to apply: always creating the ModOrder node is valid BG3 XML regardless of mod count.
|
||||
"""
|
||||
import os
|
||||
if not self.modlist_dir:
|
||||
return
|
||||
plugin_path = os.path.join(
|
||||
str(self.modlist_dir),
|
||||
"plugins", "basic_games", "games", "baldursgate3", "modSettings.py"
|
||||
)
|
||||
if not os.path.exists(plugin_path):
|
||||
self.logger.debug("BG3 modSettings.py plugin not found, skipping patch")
|
||||
return
|
||||
try:
|
||||
with open(plugin_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
buggy = (
|
||||
" if len(mod_settings) > 1:\n"
|
||||
" mods_order_node = ET.SubElement(children, \"node\")\n"
|
||||
" mods_order_node.set(\"id\", \"ModOrder\")"
|
||||
)
|
||||
fixed = (
|
||||
" mods_order_node = ET.SubElement(children, \"node\")\n"
|
||||
" mods_order_node.set(\"id\", \"ModOrder\")"
|
||||
)
|
||||
if buggy in content:
|
||||
content = content.replace(buggy, fixed)
|
||||
with open(plugin_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
self.logger.info("Applied modSettings.py patch for BG3 MO2 plugin")
|
||||
elif fixed in content:
|
||||
self.logger.debug("BG3 modSettings.py already patched, skipping")
|
||||
else:
|
||||
self.logger.warning("BG3 modSettings.py patch target not found - plugin may have changed upstream")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not patch BG3 modSettings.py: {e} (non-critical, continuing)")
|
||||
|
||||
def _set_bg3_rootbuilder_copy_mode(self) -> None:
|
||||
"""
|
||||
Switch Root Builder to copy mode in ModOrganizer.ini for BG3 modlists.
|
||||
Link mode (the shipped default) fails on Linux - files are not accessible
|
||||
to the game process across the Wine boundary. Copy mode works reliably.
|
||||
Applied unconditionally: copy mode is safe regardless of drive layout.
|
||||
Detected by presence of RootBuilder keys rather than game_var (unreliable for BG3).
|
||||
"""
|
||||
import os, re
|
||||
if not self.modlist_dir:
|
||||
return
|
||||
mo2_ini = os.path.join(str(self.modlist_dir), "ModOrganizer.ini")
|
||||
if not os.path.exists(mo2_ini):
|
||||
self.logger.debug("ModOrganizer.ini not found, skipping Root Builder copy mode patch")
|
||||
return
|
||||
try:
|
||||
with open(mo2_ini, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
if 'RootBuilder\\' not in content and 'RootBuilder/' not in content:
|
||||
self.logger.debug("Root Builder not present in ModOrganizer.ini, skipping")
|
||||
return
|
||||
content = re.sub(r'^(RootBuilder\\copyfiles\s*=).*$', r'\1**', content, flags=re.MULTILINE)
|
||||
content = re.sub(r'^(RootBuilder\\linkfiles\s*=).*$', r'\1', content, flags=re.MULTILINE)
|
||||
with open(mo2_ini, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
self.logger.info("Set Root Builder to copy mode in ModOrganizer.ini")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not set Root Builder copy mode: {e} (non-critical, continuing)")
|
||||
|
||||
@@ -253,12 +253,13 @@ class ModlistDetectionMixin:
|
||||
modlist_path = Path(self.modlist_dir)
|
||||
common_names = [
|
||||
"Stock Game",
|
||||
"Game Root",
|
||||
"StockGame",
|
||||
"STOCK GAME",
|
||||
"Stock Game Folder",
|
||||
"Stock Folder",
|
||||
"Skyrim Stock",
|
||||
Path("root/Skyrim Special Edition")
|
||||
Path("root/Skyrim Special Edition"),
|
||||
"Game Root",
|
||||
]
|
||||
|
||||
found_path = None
|
||||
@@ -326,6 +327,15 @@ class ModlistDetectionMixin:
|
||||
if mo2_ini.exists():
|
||||
try:
|
||||
content = mo2_ini.read_text(errors='ignore').lower()
|
||||
# Extract gameName= for authoritative game type checks.
|
||||
# Full-content scans can false-positive on plugin setting keys
|
||||
# (e.g. enable_skyrimVR=false in a Skyrim SE ini).
|
||||
game_name_value = ""
|
||||
for _line in content.splitlines():
|
||||
stripped_line = _line.strip()
|
||||
if stripped_line.startswith("gamename="):
|
||||
game_name_value = stripped_line[len("gamename="):]
|
||||
break
|
||||
if 'nvse' in content or 'nvse_loader' in content or 'fallout new vegas' in content or 'falloutnv' in content:
|
||||
self.logger.info("Detected FNV via ModOrganizer.ini markers")
|
||||
return "fnv"
|
||||
@@ -335,6 +345,18 @@ class ModlistDetectionMixin:
|
||||
if any(pattern in content for pattern in ['enderal launcher', 'enderal.exe', 'enderal launcher.exe', 'enderalsteam']):
|
||||
self.logger.info("Detected Enderal via ModOrganizer.ini markers")
|
||||
return "enderal"
|
||||
if 'cyberpunk 2077' in content or 'cyberpunk2077' in content or 'cp2077' in content:
|
||||
self.logger.info("Detected Cyberpunk 2077 via ModOrganizer.ini markers")
|
||||
return "cp2077"
|
||||
if "baldur's gate 3" in content or 'baldursgate3' in content or 'bg3' in content:
|
||||
self.logger.info("Detected Baldur's Gate 3 via ModOrganizer.ini markers")
|
||||
return "bg3"
|
||||
if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value:
|
||||
self.logger.info("Detected SkyrimVR via ModOrganizer.ini gameName")
|
||||
return "skyrimvr"
|
||||
if 'fallout 4 vr' in game_name_value or 'fallout4vr' in game_name_value:
|
||||
self.logger.info("Detected Fallout 4 VR via ModOrganizer.ini gameName")
|
||||
return "fallout4vr"
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}")
|
||||
except Exception:
|
||||
@@ -364,6 +386,15 @@ class ModlistDetectionMixin:
|
||||
if enderal_launcher.exists():
|
||||
self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'")
|
||||
return "enderal"
|
||||
cp2077_exe = base / "Cyberpunk2077.exe"
|
||||
if cp2077_exe.exists():
|
||||
self.logger.info(f"Detected Cyberpunk 2077 modlist: found Cyberpunk2077.exe in '{base}'")
|
||||
return "cp2077"
|
||||
bg3_exe = base / "bg3.exe"
|
||||
bg3_dx11_exe = base / "bg3_dx11.exe"
|
||||
if bg3_exe.exists() or bg3_dx11_exe.exists():
|
||||
self.logger.info(f"Detected BG3 modlist: found BG3 executable in '{base}'")
|
||||
return "bg3"
|
||||
|
||||
# Final heuristic using game_var
|
||||
try:
|
||||
@@ -379,6 +410,18 @@ class ModlistDetectionMixin:
|
||||
if 'enderal' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates Enderal")
|
||||
return "enderal"
|
||||
if 'cyberpunk' in gt or 'cp2077' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates Cyberpunk 2077")
|
||||
return "cp2077"
|
||||
if "baldur" in gt or 'bg3' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates BG3")
|
||||
return "bg3"
|
||||
if 'skyrim vr' in gt or 'skyrimvr' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates SkyrimVR")
|
||||
return "skyrimvr"
|
||||
if 'fallout 4 vr' in gt or 'fallout4vr' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates Fallout 4 VR")
|
||||
return "fallout4vr"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -61,32 +61,9 @@ class ModlistHandler(ModlistDetectionMixin, ModlistConfigurationMixin, ModlistWi
|
||||
Handles operations related to modlist detection and configuration
|
||||
"""
|
||||
|
||||
# Dictionary mapping modlist name patterns (lowercase, spaces optional)
|
||||
# to lists of additional Wine components or special actions.
|
||||
MODLIST_SPECIFIC_COMPONENTS = {
|
||||
# Pattern: [component1, component2, ... or special_action_string]
|
||||
"wildlander": ["dotnet48"], # Example from bash script
|
||||
"licentia": ["dotnet8"], # Example from bash script (needs special handling)
|
||||
"nolvus": ["dotnet6", "dotnet7"], # Example
|
||||
# Add other modlists and their specific needs here
|
||||
# e.g., "fallout4_anotherlife": ["some_component"]
|
||||
}
|
||||
|
||||
# Canonical mapping of modlist-specific Wine components (from omni-guides.sh)
|
||||
# dotnet4.x components disabled in v0.1.6.2 -- replaced with universal registry fixes
|
||||
MODLIST_WINE_COMPONENTS = {
|
||||
# "wildlander": ["dotnet472"], # DISABLED: Universal registry fixes replace dotnet472 installation
|
||||
# "librum": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
|
||||
"librum": ["dotnet8"], # dotnet40 replaced with universal registry fixes
|
||||
# "apostasy": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
|
||||
"apostasy": ["dotnet8"], # dotnet40 replaced with universal registry fixes
|
||||
# "nordicsouls": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "livingskyrim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "lsiv": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "ls4": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "lorerim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "lostlegacy": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
}
|
||||
MODLIST_SPECIFIC_COMPONENTS: dict = {}
|
||||
|
||||
MODLIST_WINE_COMPONENTS: dict = {}
|
||||
|
||||
def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None,
|
||||
mo2_path: Optional[Union[str, Path]] = None,
|
||||
|
||||
@@ -159,14 +159,17 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
self.logger.info(f"Using machineid: {machineid}")
|
||||
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
||||
|
||||
writeback_path = str(auth_service.get_token_writeback_path())
|
||||
# Store original environment values to restore later
|
||||
original_env_values = {
|
||||
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
|
||||
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
|
||||
'JACKIFY_TOKEN_WRITEBACK': os.environ.get('JACKIFY_TOKEN_WRITEBACK'),
|
||||
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
|
||||
}
|
||||
|
||||
try:
|
||||
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
|
||||
# Temporarily modify current process's environment
|
||||
# Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy)
|
||||
if oauth_info:
|
||||
@@ -341,7 +344,8 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
print()
|
||||
|
||||
proc.wait()
|
||||
|
||||
auth_service.apply_token_writeback(writeback_path)
|
||||
|
||||
finally:
|
||||
# Stop performance monitoring and get summary
|
||||
if monitoring_started:
|
||||
|
||||
@@ -59,33 +59,11 @@ class ModlistWineOpsMixin:
|
||||
self.logger.error("Could not locate Steam's config.vdf file.")
|
||||
return False, 'config_vdf_missing'
|
||||
|
||||
# Add a short delay to allow Steam to potentially finish writing changes
|
||||
self.logger.debug("Waiting 2 seconds before reading config.vdf...")
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Attempting to load VDF file: {config_vdf_path}")
|
||||
# CORRECTION: Use the vdf library directly here, not VDFHandler
|
||||
self.logger.debug(f"Loading config.vdf: {config_vdf_path}")
|
||||
with open(str(config_vdf_path), 'r') as f:
|
||||
config_data = vdf.load(f, mapper=vdf.VDFDict)
|
||||
config_data = vdf.load(f, mapper=vdf.VDFDict)
|
||||
|
||||
# --- Write full config.vdf to a debug file ---
|
||||
debug_dump_path = os.path.expanduser("~/dev/Jackify/configvdf_dump.txt")
|
||||
with open(debug_dump_path, "w") as dump_f:
|
||||
json.dump(config_data, dump_f, indent=2)
|
||||
self.logger.info(f"Full config.vdf dumped to {debug_dump_path}")
|
||||
|
||||
# --- Log only the relevant section for this AppID ---
|
||||
steam_config_section = config_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {})
|
||||
compat_mapping = steam_config_section.get('CompatToolMapping', {})
|
||||
app_mapping = compat_mapping.get(appid_to_check, {})
|
||||
self.logger.debug("───────────────────────────────────────────────────────────────────")
|
||||
self.logger.debug(f"Config.vdf entry for AppID {appid_to_check} (CompatToolMapping):")
|
||||
self.logger.debug(json.dumps({appid_to_check: app_mapping}, indent=2))
|
||||
self.logger.debug("───────────────────────────────────────────────────────────────────")
|
||||
self.logger.debug(f"Steam config section from VDF: {json.dumps(steam_config_section, indent=2)}")
|
||||
# --- End Debugging ---
|
||||
|
||||
# Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name
|
||||
compat_mapping = steam_config_section.get('CompatToolMapping', {})
|
||||
app_mapping = compat_mapping.get(appid_to_check, {})
|
||||
@@ -152,14 +130,24 @@ class ModlistWineOpsMixin:
|
||||
self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.")
|
||||
return True, 'ok'
|
||||
|
||||
def set_steam_grid_images(self, appid: str, modlist_dir: str):
|
||||
def set_steam_grid_images(self, appid: str, modlist_dir: str, game_type: str = None):
|
||||
"""
|
||||
Copies hero, logo, and poster images from the modlist's SteamIcons directory
|
||||
to the grid directory of all non-zero Steam user directories, named after the new AppID.
|
||||
Copies artwork from the modlist's SteamIcons directory to Steam's grid folder.
|
||||
Falls back to SteamGridDB if no SteamIcons directory is present and an API key
|
||||
is configured.
|
||||
"""
|
||||
if modlist_dir:
|
||||
try:
|
||||
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
|
||||
detected_game_type = detect_game_type_from_modlist(modlist_dir)
|
||||
if detected_game_type:
|
||||
game_type = detected_game_type
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Steam artwork game type auto-detect failed for {modlist_dir}: {e}")
|
||||
|
||||
steam_icons_dir = Path(modlist_dir) / "SteamIcons"
|
||||
if not steam_icons_dir.is_dir():
|
||||
self.logger.info(f"No SteamIcons directory found at {steam_icons_dir}, skipping grid image copy.")
|
||||
self._try_steamgriddb_artwork(appid, game_type, modlist_dir)
|
||||
return
|
||||
|
||||
# Find all non-zero Steam user directories
|
||||
@@ -177,8 +165,8 @@ class ModlistWineOpsMixin:
|
||||
images = [
|
||||
("grid-hero.png", f"{appid}_hero.png"),
|
||||
("grid-logo.png", f"{appid}_logo.png"),
|
||||
("grid-tall.png", f"{appid}.png"),
|
||||
("grid-tall.png", f"{appid}p.png"),
|
||||
("grid-wide.png", f"{appid}.png"),
|
||||
]
|
||||
|
||||
for src_name, dest_name in images:
|
||||
@@ -191,7 +179,85 @@ class ModlistWineOpsMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}")
|
||||
else:
|
||||
self.logger.warning(f"Image {src_path} not found; skipping.")
|
||||
self.logger.debug(f"Image {src_path} not found; skipping.")
|
||||
|
||||
# Tenfoot: use explicit file if provided, otherwise resize the landscape grid
|
||||
tenfoot_src = steam_icons_dir / "grid-tenfoot.png"
|
||||
tenfoot_dest = grid_dir / f"{appid}_tenfoot.png"
|
||||
wide_src = steam_icons_dir / "grid-wide.png"
|
||||
if tenfoot_src.exists():
|
||||
try:
|
||||
shutil.copyfile(tenfoot_src, tenfoot_dest)
|
||||
self.logger.info(f"Copied {tenfoot_src} to {tenfoot_dest}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to copy tenfoot image: {e}")
|
||||
elif wide_src.exists():
|
||||
try:
|
||||
from PySide6.QtGui import QImage
|
||||
img = QImage(str(wide_src))
|
||||
if not img.isNull():
|
||||
scaled = img.scaled(600, 350)
|
||||
scaled.save(str(tenfoot_dest))
|
||||
self.logger.info(f"Generated tenfoot image from landscape: {tenfoot_dest}")
|
||||
else:
|
||||
self.logger.warning(f"Could not load landscape image for tenfoot generation: {wide_src}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not generate tenfoot image: {e}")
|
||||
|
||||
def _try_steamgriddb_artwork(self, appid: str, game_type: str = None, modlist_dir: str = None):
|
||||
"""Fetch default artwork from SteamGridDB when no modlist-provided SteamIcons exist."""
|
||||
if not game_type and modlist_dir:
|
||||
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
|
||||
game_type = detect_game_type_from_modlist(modlist_dir)
|
||||
if not game_type:
|
||||
self.logger.warning(f"SteamGridDB fallback skipped: could not detect game type for {modlist_dir}")
|
||||
return
|
||||
|
||||
userdata_base = Path.home() / ".steam/steam/userdata"
|
||||
if not userdata_base.is_dir():
|
||||
return
|
||||
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
from jackify.backend.services.steamgriddb_service import fetch_artwork
|
||||
count = fetch_artwork(game_type, tmp_dir)
|
||||
if count == 0:
|
||||
self.logger.debug(f"SteamGridDB returned no artwork for game type: {game_type}")
|
||||
return
|
||||
|
||||
for user_dir in userdata_base.iterdir():
|
||||
if not user_dir.is_dir() or user_dir.name == "0":
|
||||
continue
|
||||
grid_dir = user_dir / "config/grid"
|
||||
grid_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
images = [
|
||||
("grid-tall.png", f"{appid}p.png"),
|
||||
("grid-wide.png", f"{appid}.png"),
|
||||
("grid-hero.png", f"{appid}_hero.png"),
|
||||
("grid-logo.png", f"{appid}_logo.png"),
|
||||
]
|
||||
for src_name, dest_name in images:
|
||||
src = tmp_dir / src_name
|
||||
if src.exists():
|
||||
try:
|
||||
shutil.copyfile(src, grid_dir / dest_name)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to copy {src_name}: {e}")
|
||||
|
||||
# Generate tenfoot from landscape
|
||||
wide = tmp_dir / "grid-wide.png"
|
||||
if wide.exists():
|
||||
try:
|
||||
from PySide6.QtGui import QImage
|
||||
img = QImage(str(wide))
|
||||
if not img.isNull():
|
||||
img.scaled(600, 350).save(str(grid_dir / f"{appid}_tenfoot.png"))
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not generate tenfoot: {e}")
|
||||
|
||||
self.logger.info(f"Applied SteamGridDB artwork for game type '{game_type}' ({count} images)")
|
||||
|
||||
def get_modlist_wine_components(self, modlist_name, game_var_full=None):
|
||||
"""
|
||||
@@ -206,12 +272,16 @@ class ModlistWineOpsMixin:
|
||||
game = (game_var_full or modlist_name or "").lower().replace(" ", "")
|
||||
# Add game-specific extras
|
||||
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"]
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
||||
elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game:
|
||||
extras += ["d3dx9_43", "d3dx9"]
|
||||
elif "cp2077" in game or "cyberpunk" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
||||
elif "bg3" in game or "baldursgate" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
||||
else:
|
||||
# Unknown game type — install the union of all known component sets
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "d3dx9_43", "d3dx9"]
|
||||
# Unknown game type - install the union of all known component sets
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "d3dx9_43", "d3dx9"]
|
||||
# Add modlist-specific extras
|
||||
modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else ""
|
||||
for key, components in self.MODLIST_WINE_COMPONENTS.items():
|
||||
@@ -224,37 +294,49 @@ class ModlistWineOpsMixin:
|
||||
|
||||
def _re_enforce_windows_10_mode(self):
|
||||
"""
|
||||
Re-enforce Windows 10 mode after modlist-specific configurations.
|
||||
This matches the legacy script behavior (line 1333) where Windows 10 mode
|
||||
is re-applied after modlist-specific steps to ensure consistency.
|
||||
Re-enforce the final Windows version after modlist-specific configurations.
|
||||
Re-applies win10 after modlist-specific winetricks components, which can
|
||||
leave the prefix at a lower version.
|
||||
"""
|
||||
try:
|
||||
if not hasattr(self, 'appid') or not self.appid:
|
||||
self.logger.warning("Cannot re-enforce Windows 10 mode - no AppID available")
|
||||
self.logger.warning("Cannot re-enforce Windows 11 mode - no AppID available")
|
||||
return
|
||||
|
||||
from ..handlers.winetricks_handler import WinetricksHandler
|
||||
from ..handlers.path_handler import PathHandler
|
||||
|
||||
# Get prefix path for the AppID
|
||||
prefix_path = PathHandler.find_compat_data(str(self.appid))
|
||||
if not prefix_path:
|
||||
self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found")
|
||||
# Get prefix path for the AppID - must be compatdata/pfx/, not compatdata/
|
||||
compatdata_path = PathHandler.find_compat_data(str(self.appid))
|
||||
if not compatdata_path:
|
||||
self.logger.warning("Cannot re-enforce Windows 11 mode - prefix path not found")
|
||||
return
|
||||
prefix_path = compatdata_path / "pfx"
|
||||
|
||||
# Use winetricks handler to set Windows 10 mode
|
||||
# Use winetricks handler to set Windows 11 mode
|
||||
winetricks_handler = WinetricksHandler()
|
||||
wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path))
|
||||
if not wine_binary:
|
||||
self.logger.warning("Cannot re-enforce Windows 10 mode - wine binary not found")
|
||||
self.logger.warning("Cannot re-enforce Windows 11 mode - wine binary not found")
|
||||
return
|
||||
|
||||
winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary)
|
||||
|
||||
self.logger.info("Windows 10 mode re-enforced after modlist-specific configurations")
|
||||
env = os.environ.copy()
|
||||
env['WINEPREFIX'] = str(prefix_path)
|
||||
env['WINE'] = wine_binary
|
||||
result = subprocess.run(
|
||||
[winetricks_handler.winetricks_path, '-q', 'win10'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
if result.returncode == 0:
|
||||
self.logger.info("Windows 11 mode re-enforced after modlist-specific configurations")
|
||||
else:
|
||||
self.logger.warning("Could not set Windows 11 mode: %s", result.stderr)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error re-enforcing Windows 10 mode: {e}")
|
||||
self.logger.warning(f"Error re-enforcing Windows 11 mode: {e}")
|
||||
|
||||
def _handle_symlinked_downloads(self) -> bool:
|
||||
"""
|
||||
@@ -380,21 +462,17 @@ class ModlistWineOpsMixin:
|
||||
env['WINEPREFIX'] = prefix_path
|
||||
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
|
||||
|
||||
# Shutdown any running wineserver processes to ensure clean slate
|
||||
if wineserver_binary:
|
||||
self.logger.debug("Shutting down wineserver before applying registry fixes...")
|
||||
try:
|
||||
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
|
||||
self.logger.debug("Wineserver shutdown complete")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}")
|
||||
self._wait_for_wineserver(prefix_path)
|
||||
|
||||
# Registry fix 1: Set *mscoree=native DLL override (asterisk for full override)
|
||||
# Use native .NET runtime instead of Wine's
|
||||
self.logger.debug("Setting *mscoree=native DLL override...")
|
||||
# Registry fix 1: Set *mscoree=native as a per-exe AppDefaults override for
|
||||
# SkyrimSE.exe only. A global DllOverrides entry breaks .NET 9/10 bootstrap
|
||||
# (Synthesis), because the override intercepts mscoree loading for ALL processes
|
||||
# including the SDK host. Scoping it to SkyrimSE.exe isolates the fix to the
|
||||
# game process without affecting Synthesis or any other .NET tool.
|
||||
self.logger.debug("Setting *mscoree=native AppDefaults override for SkyrimSE.exe...")
|
||||
cmd1 = [
|
||||
wine_binary, 'reg', 'add',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides',
|
||||
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
|
||||
]
|
||||
|
||||
@@ -430,43 +508,12 @@ class ModlistWineOpsMixin:
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Registry flush failed (non-critical): {e}")
|
||||
|
||||
# VERIFICATION: Confirm the registry entries persisted
|
||||
self.logger.info("Verifying registry entries were applied and persisted...")
|
||||
verification_passed = True
|
||||
|
||||
# Verify *mscoree=native
|
||||
verify_cmd1 = [
|
||||
wine_binary, 'reg', 'query',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
|
||||
'/v', '*mscoree'
|
||||
]
|
||||
verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
|
||||
if verify_result1.returncode == 0 and 'native' in verify_result1.stdout:
|
||||
self.logger.info("VERIFIED: *mscoree=native is set correctly")
|
||||
ok = result1.returncode == 0 and result2.returncode == 0
|
||||
if ok:
|
||||
self.logger.info("Universal dotnet4.x compatibility fixes applied and flushed")
|
||||
else:
|
||||
self.logger.error(f"VERIFICATION FAILED: *mscoree=native not found in registry. Query output: {verify_result1.stdout}")
|
||||
verification_passed = False
|
||||
|
||||
# Verify OnlyUseLatestCLR=1
|
||||
verify_cmd2 = [
|
||||
wine_binary, 'reg', 'query',
|
||||
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
|
||||
'/v', 'OnlyUseLatestCLR'
|
||||
]
|
||||
verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
|
||||
if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout):
|
||||
self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly")
|
||||
else:
|
||||
self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}")
|
||||
verification_passed = False
|
||||
|
||||
# Both fixes applied and verified
|
||||
if result1.returncode == 0 and result2.returncode == 0 and verification_passed:
|
||||
self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully")
|
||||
return True
|
||||
else:
|
||||
self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts")
|
||||
return False
|
||||
self.logger.error("One or more dotnet4.x registry commands failed - see errors above")
|
||||
return ok
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
|
||||
@@ -506,6 +553,204 @@ class ModlistWineOpsMixin:
|
||||
self.logger.error(f"Error finding Wine binary: {e}")
|
||||
return None
|
||||
|
||||
def _wait_for_wineserver(self, prefix_path: str) -> None:
|
||||
"""Wait for wineserver to stop for the given prefix before direct file edits.
|
||||
|
||||
Harmless if wineserver is already stopped - exits immediately.
|
||||
Prevents in-memory hive flush from overwriting direct .reg file edits.
|
||||
"""
|
||||
wine_binary = self._find_wine_binary_for_registry()
|
||||
if not wine_binary:
|
||||
self.logger.debug("No wine binary found; skipping wineserver wait")
|
||||
return
|
||||
wineserver = os.path.join(os.path.dirname(wine_binary), "wineserver")
|
||||
if not os.path.exists(wineserver):
|
||||
self.logger.debug("wineserver binary not found; skipping wait")
|
||||
return
|
||||
env = os.environ.copy()
|
||||
env["WINEPREFIX"] = prefix_path
|
||||
env["WINEDEBUG"] = "-all"
|
||||
try:
|
||||
subprocess.run([wineserver, "-w"], env=env, timeout=30, capture_output=True)
|
||||
self.logger.debug("wineserver stopped for prefix %s", prefix_path)
|
||||
except Exception as e:
|
||||
self.logger.debug("wineserver wait returned non-zero (likely already stopped): %s", e)
|
||||
|
||||
def _apply_modlist_registry_tweaks(self) -> bool:
|
||||
"""Write user.reg values required for modlist operation.
|
||||
|
||||
- FontSmoothing/Type/Gamma/Orientation (ClearType subpixel rendering)
|
||||
- HIGHDPIAWARE (prevents Wine DPI scaling on tools)
|
||||
- ShowDotFiles=Y (MO2 must see hidden dirs inside the prefix)
|
||||
"""
|
||||
try:
|
||||
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
|
||||
user_reg = os.path.join(prefix_path, "user.reg")
|
||||
if not os.path.exists(user_reg):
|
||||
self.logger.warning("user.reg not found at %s; skipping modlist registry tweaks", user_reg)
|
||||
return False
|
||||
|
||||
self._wait_for_wineserver(prefix_path)
|
||||
|
||||
tweaks = [
|
||||
(
|
||||
"[Control Panel\\\\Desktop]",
|
||||
'"FontSmoothing"',
|
||||
'"2"',
|
||||
),
|
||||
(
|
||||
"[Control Panel\\\\Desktop]",
|
||||
'"FontSmoothingGamma"',
|
||||
"dword:00000578",
|
||||
),
|
||||
(
|
||||
"[Control Panel\\\\Desktop]",
|
||||
'"FontSmoothingOrientation"',
|
||||
"dword:00000001",
|
||||
),
|
||||
(
|
||||
"[Control Panel\\\\Desktop]",
|
||||
'"FontSmoothingType"',
|
||||
"dword:00000002",
|
||||
),
|
||||
(
|
||||
"[Software\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\AppCompatFlags\\\\Layers]",
|
||||
'@',
|
||||
'"~ HIGHDPIAWARE"',
|
||||
),
|
||||
(
|
||||
"[Software\\\\Wine]",
|
||||
'"ShowDotFiles"',
|
||||
'"Y"',
|
||||
),
|
||||
]
|
||||
|
||||
with open(user_reg, "r", encoding="utf-8", errors="ignore") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
for section, key, value in tweaks:
|
||||
in_section = False
|
||||
updated = False
|
||||
insert_at = None
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.lower() == section.lower():
|
||||
in_section = True
|
||||
continue
|
||||
if stripped.startswith("[") and in_section:
|
||||
insert_at = i
|
||||
break
|
||||
if in_section and stripped.lower().startswith(key.lower()):
|
||||
lines[i] = f"{key}={value}\n"
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
entry = f"{key}={value}\n"
|
||||
if insert_at is not None:
|
||||
lines.insert(insert_at, entry)
|
||||
elif in_section:
|
||||
lines.append(entry)
|
||||
else:
|
||||
lines.append(f"\n{section}\n")
|
||||
lines.append(entry)
|
||||
|
||||
with open(user_reg, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
self.logger.info("Modlist registry tweaks applied (font smoothing, HIGHDPIAWARE, ShowDotFiles)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to apply modlist registry tweaks: %s", e)
|
||||
return False
|
||||
|
||||
def _audit_registry_state(self) -> bool:
|
||||
"""Read user.reg and system.reg and log whether every expected value is present.
|
||||
|
||||
Returns True only when all checks pass. Logs a WARNING for each missing or
|
||||
wrong value so the application log always carries a clear post-configuration
|
||||
record of registry state.
|
||||
"""
|
||||
try:
|
||||
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
|
||||
user_reg = os.path.join(prefix_path, "user.reg")
|
||||
system_reg = os.path.join(prefix_path, "system.reg")
|
||||
|
||||
def _read(path):
|
||||
if not os.path.exists(path):
|
||||
return ""
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return f.read()
|
||||
|
||||
user_content = _read(user_reg)
|
||||
system_content = _read(system_reg)
|
||||
|
||||
checks = [
|
||||
# (description, file_content, expected_substring)
|
||||
(
|
||||
"ShowDotFiles=Y (user.reg)",
|
||||
user_content,
|
||||
'"ShowDotFiles"="Y"',
|
||||
),
|
||||
(
|
||||
"FontSmoothing=2 (user.reg)",
|
||||
user_content,
|
||||
'"FontSmoothing"="2"',
|
||||
),
|
||||
(
|
||||
"FontSmoothingType=2 (user.reg)",
|
||||
user_content,
|
||||
'"FontSmoothingType"=dword:00000002',
|
||||
),
|
||||
(
|
||||
"FontSmoothingGamma (user.reg)",
|
||||
user_content,
|
||||
'"FontSmoothingGamma"=dword:00000578',
|
||||
),
|
||||
(
|
||||
"FontSmoothingOrientation (user.reg)",
|
||||
user_content,
|
||||
'"FontSmoothingOrientation"=dword:00000001',
|
||||
),
|
||||
(
|
||||
"HIGHDPIAWARE (user.reg)",
|
||||
user_content,
|
||||
'HIGHDPIAWARE',
|
||||
),
|
||||
(
|
||||
"*mscoree=native (user.reg)",
|
||||
user_content,
|
||||
'"*mscoree"="native"',
|
||||
),
|
||||
(
|
||||
"OnlyUseLatestCLR=1 (system.reg)",
|
||||
system_content,
|
||||
'"OnlyUseLatestCLR"=dword:00000001',
|
||||
),
|
||||
]
|
||||
|
||||
all_ok = True
|
||||
for description, content, needle in checks:
|
||||
if needle in content:
|
||||
self.logger.info("Registry audit [OK] %s", description)
|
||||
else:
|
||||
self.logger.warning("Registry audit [MISSING] %s", description)
|
||||
all_ok = False
|
||||
|
||||
if all_ok:
|
||||
self.logger.info("Registry audit complete - all values confirmed present")
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Registry audit complete - one or more values missing; "
|
||||
"see [MISSING] entries above"
|
||||
)
|
||||
return all_ok
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Registry audit failed with exception: %s", e)
|
||||
return False
|
||||
|
||||
def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Recursively search for wine binary within a Proton directory.
|
||||
@@ -543,4 +788,3 @@ class ModlistWineOpsMixin:
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -180,5 +180,6 @@ class PathHandlerGameMixin:
|
||||
self.stock_game_path = found_path
|
||||
return True
|
||||
self.stock_game_path = None
|
||||
self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.")
|
||||
searched = [str(modlist_path / n) for n in preferred_order]
|
||||
self.logger.info(f"No common Stock Game/Game Root directory found (searched: {searched}). Will assume vanilla game path is needed for some operations.")
|
||||
return True
|
||||
|
||||
@@ -534,7 +534,8 @@ class PathHandlerMO2Mixin:
|
||||
def set_download_directory(self, modlist_ini_path: Path, download_dir_linux_path, modlist_sdcard: bool) -> bool:
|
||||
"""
|
||||
Set download_directory in ModOrganizer.ini to the correct Wine path (Z: or D: for SD card).
|
||||
Use only when download dir is known (e.g. Install a Modlist flow). Configure New/Existing leave as-is.
|
||||
Replaces ALL occurrences of the key throughout the file - MO2 reads the last one, and
|
||||
duplicate [General] sections from Wabbajack installs are common.
|
||||
"""
|
||||
if not modlist_ini_path.is_file() or not download_dir_linux_path:
|
||||
return False
|
||||
@@ -553,35 +554,62 @@ class PathHandlerMO2Mixin:
|
||||
formatted = PathHandlerMO2Mixin._format_workingdir_for_mo2(wine_path)
|
||||
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
in_general = False
|
||||
download_line_idx = -1
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE):
|
||||
in_general = True
|
||||
continue
|
||||
if in_general and re.match(r'^\s*\[', line):
|
||||
break
|
||||
if in_general and re.match(r'^\s*download_directory\s*=', line, re.IGNORECASE):
|
||||
download_line_idx = i
|
||||
break
|
||||
new_line = f"download_directory = {formatted}\n"
|
||||
if download_line_idx >= 0:
|
||||
lines[download_line_idx] = new_line
|
||||
replaced = [i for i, l in enumerate(lines) if re.match(r'^\s*download_directory\s*=', l, re.IGNORECASE)]
|
||||
if replaced:
|
||||
for i in replaced:
|
||||
lines[i] = new_line
|
||||
else:
|
||||
if in_general:
|
||||
insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1)
|
||||
if insert_idx >= 0:
|
||||
# No existing entry - insert after [General]
|
||||
insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1)
|
||||
if insert_idx >= 0:
|
||||
insert_idx += 1
|
||||
while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]):
|
||||
insert_idx += 1
|
||||
while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]):
|
||||
insert_idx += 1
|
||||
lines.insert(insert_idx, new_line)
|
||||
lines.insert(insert_idx, new_line)
|
||||
else:
|
||||
lines.append("[General]\n")
|
||||
lines.append(new_line)
|
||||
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
logger.info(f"Set download_directory in ModOrganizer.ini to {formatted}")
|
||||
logger.info(f"Set download_directory in ModOrganizer.ini to {formatted} ({len(replaced)} occurrence(s))")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting download_directory in {modlist_ini_path}: {e}")
|
||||
return False
|
||||
|
||||
def get_download_directory_linux_path(self, modlist_ini_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Read the first valid download_directory value from ModOrganizer.ini and convert to a Linux path.
|
||||
Returns None if no valid Z: or D: path is found.
|
||||
"""
|
||||
if not modlist_ini_path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(modlist_ini_path, 'r', encoding='utf-8-sig') as f:
|
||||
lines = f.readlines()
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
with open(modlist_ini_path, 'r', encoding='latin-1') as f:
|
||||
lines = f.readlines()
|
||||
except Exception:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
for line in lines:
|
||||
m = re.match(r'^\s*download_directory\s*=\s*(.+)$', line, re.IGNORECASE)
|
||||
if not m:
|
||||
continue
|
||||
raw = m.group(1).strip()
|
||||
# Expect Z:\\path\\... or D:\\path\\... (MO2 doubles backslashes in the file)
|
||||
drive_m = re.match(r'^([ZzDd]):(.+)$', raw)
|
||||
if not drive_m:
|
||||
continue
|
||||
drive, rest = drive_m.group(1).upper(), drive_m.group(2)
|
||||
# Collapse doubled backslashes back to single separators
|
||||
rest = re.sub(r'\\\\', '/', rest).replace('\\', '/')
|
||||
if drive == 'Z':
|
||||
return '/' + rest.lstrip('/')
|
||||
# D: (SD card) - return as-is with leading slash; caller handles sdcard prefix
|
||||
return '/' + rest.lstrip('/')
|
||||
return None
|
||||
|
||||
@@ -90,7 +90,7 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
# Alternative format: "[timestamp] StatusText (current/total) - speed [- Xunit remaining]"
|
||||
# Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s"
|
||||
# Example (engine 0.4.8+): "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s - 23.1GB remaining"
|
||||
# Timestamp prefix is now optional — engine no longer emits [HH:MM:SS].
|
||||
# Timestamp prefix is now optional - engine no longer emits [HH:MM:SS].
|
||||
self.timestamp_status_pattern = re.compile(
|
||||
r'(?:\[[^\]]+\]\s+)?(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)(?:\s*-\s*([\d.]+)\s*(B|KB|MB|GB|TB)\s+remaining)?',
|
||||
re.IGNORECASE
|
||||
@@ -157,10 +157,17 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
ParsedLine with extracted information
|
||||
"""
|
||||
result = ParsedLine(message=line.strip())
|
||||
|
||||
|
||||
if not line.strip():
|
||||
return result
|
||||
|
||||
|
||||
# Suppress internal engine lines that are not user-facing
|
||||
_suppress_prefixes = (
|
||||
"Refreshing OAuth Token",
|
||||
)
|
||||
if any(line.strip().startswith(p) for p in _suppress_prefixes):
|
||||
return ParsedLine()
|
||||
|
||||
# Try to extract phase information
|
||||
phase_info = self._extract_phase(line)
|
||||
if phase_info:
|
||||
|
||||
@@ -20,11 +20,11 @@ class ProgressParserPhaseMixin:
|
||||
phase = self._map_section_to_phase(section_name)
|
||||
return (phase, section_match.group(1).strip())
|
||||
|
||||
# [FILE_PROGRESS] lines drive file activity only — skip phase extraction for them
|
||||
# [FILE_PROGRESS] lines drive file activity only - skip phase extraction for them
|
||||
if '[FILE_PROGRESS]' in line:
|
||||
return None
|
||||
|
||||
# Make the [timestamp] prefix optional — engine no longer emits it.
|
||||
# Make the [timestamp] prefix optional - engine no longer emits it.
|
||||
action_match = re.search(
|
||||
r'(?:\[.*?\]\s*)?(Installing|Downloading|Extracting|Validating|Processing|Checking existing)',
|
||||
line,
|
||||
|
||||
@@ -87,7 +87,7 @@ class ProtontricksCommandsMixin:
|
||||
env['WINETRICKS'] = str(winetricks_path)
|
||||
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
|
||||
else:
|
||||
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||
self.logger.info("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||
cabextract_path = self._get_bundled_cabextract_path()
|
||||
if cabextract_path:
|
||||
cabextract_dir = str(cabextract_path.parent)
|
||||
@@ -95,7 +95,7 @@ class ProtontricksCommandsMixin:
|
||||
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
|
||||
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
|
||||
else:
|
||||
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||
self.logger.info("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||
else:
|
||||
self.logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ class ProtontricksPrefixMixin:
|
||||
self.logger.debug("ShowDotFiles already present in correct format in user.reg")
|
||||
dotfiles_set_success = True
|
||||
else:
|
||||
self.logger.warning(f"user.reg not found at {user_reg_path}, creating it.")
|
||||
self.logger.info(f"user.reg not found at {user_reg_path}, creating it.")
|
||||
with open(user_reg_path, 'w', encoding='utf-8') as f:
|
||||
f.write('[Software\\\\Wine] 1603891765\n')
|
||||
f.write('"ShowDotFiles"="Y"\n')
|
||||
@@ -157,6 +157,10 @@ class ProtontricksPrefixMixin:
|
||||
self.logger.info("=" * 80)
|
||||
env = self._get_clean_subprocess_env()
|
||||
env["WINEDEBUG"] = "-all"
|
||||
# Preserve the desktop display variables for Step 4. The validated fix
|
||||
# for the blank taskbar popup regression was keeping DISPLAY available.
|
||||
# Do not strip extra desktop activation vars here without a reproduced,
|
||||
# evidence-backed need.
|
||||
|
||||
if self.which_protontricks == 'native':
|
||||
winetricks_path = self._get_bundled_winetricks_path()
|
||||
@@ -164,7 +168,7 @@ class ProtontricksPrefixMixin:
|
||||
env['WINETRICKS'] = str(winetricks_path)
|
||||
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
|
||||
else:
|
||||
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||
self.logger.info("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||
cabextract_path = self._get_bundled_cabextract_path()
|
||||
if cabextract_path:
|
||||
cabextract_dir = str(cabextract_path.parent)
|
||||
@@ -172,7 +176,7 @@ class ProtontricksPrefixMixin:
|
||||
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
|
||||
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
|
||||
else:
|
||||
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||
self.logger.info("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||
else:
|
||||
self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ class ProtontricksSteamMixin:
|
||||
self.logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}")
|
||||
|
||||
if steamdeck:
|
||||
self.logger.warning("Checking for SDCard and setting permissions appropriately...")
|
||||
self.logger.info("Checking for SDCard and setting permissions appropriately...")
|
||||
result = subprocess.run(["df", "-h"], capture_output=True, text=True, env=env)
|
||||
for line in result.stdout.splitlines():
|
||||
if "/run/media" in line:
|
||||
|
||||
@@ -104,12 +104,16 @@ class ShortcutCreationMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error determining STEAM_COMPAT_MOUNTS: {e}", exc_info=True)
|
||||
|
||||
dotnet_vars = 'DOTNET_ROOT="" DOTNET_MULTILEVEL_LOOKUP=0'
|
||||
|
||||
final_launch_options = launch_options
|
||||
if compat_mounts_str:
|
||||
if final_launch_options:
|
||||
final_launch_options = f"{compat_mounts_str} {final_launch_options}"
|
||||
else:
|
||||
final_launch_options = compat_mounts_str
|
||||
env_prefix_parts = [p for p in [compat_mounts_str, dotnet_vars] if p]
|
||||
if env_prefix_parts:
|
||||
prefix = " ".join(env_prefix_parts)
|
||||
if final_launch_options:
|
||||
final_launch_options = f"{prefix} {final_launch_options}"
|
||||
else:
|
||||
final_launch_options = prefix
|
||||
|
||||
if not final_launch_options.strip().endswith("%command%"):
|
||||
if final_launch_options:
|
||||
@@ -138,7 +142,6 @@ class ShortcutCreationMixin:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating shortcut: {e}", exc_info=True)
|
||||
print(f"An error occurred while creating the shortcut: {e}")
|
||||
return False, None
|
||||
|
||||
def _is_steam_deck(self):
|
||||
|
||||
@@ -165,7 +165,7 @@ class ShortcutDiscoveryMixin:
|
||||
self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)")
|
||||
return str(int(appid) & 0xFFFFFFFF)
|
||||
|
||||
self.logger.warning(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
|
||||
self.logger.debug(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -300,7 +300,6 @@ class ShortcutVDFManagementMixin:
|
||||
try:
|
||||
shutil.copy2(safe_backup, shortcuts_file)
|
||||
self.logger.info(f"Restored shortcuts.vdf from pre-restart backup")
|
||||
print("Restored shortcuts file after Steam restart")
|
||||
return
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to restore from pre-restart backup: {e}")
|
||||
@@ -310,9 +309,8 @@ class ShortcutVDFManagementMixin:
|
||||
try:
|
||||
shutil.copy2(backup, shortcuts_file)
|
||||
self.logger.info(f"Restored shortcuts.vdf from regular backup")
|
||||
print("Restored shortcuts file after Steam restart")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to restore from backup: {e}")
|
||||
print("Failed to restore shortcuts file. You may need to recreate your shortcut.")
|
||||
self.logger.warning("shortcuts.vdf restore failed - shortcut may need to be recreated")
|
||||
else:
|
||||
self.logger.info(f"shortcuts.vdf verified intact after restart")
|
||||
|
||||
@@ -8,6 +8,31 @@ import shutil
|
||||
import logging
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def suspend_baloo() -> bool:
|
||||
"""Suspend KDE Baloo file indexer. Safe to call on non-KDE or headless systems."""
|
||||
if not shutil.which("balooctl"):
|
||||
return False
|
||||
try:
|
||||
subprocess.run(["balooctl", "suspend"], capture_output=True, timeout=5)
|
||||
logger.debug("Baloo file indexer suspended")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def resume_baloo() -> None:
|
||||
"""Resume KDE Baloo file indexer. No-op if balooctl is not present."""
|
||||
if not shutil.which("balooctl"):
|
||||
return
|
||||
try:
|
||||
subprocess.run(["balooctl", "resume"], capture_output=True, timeout=5)
|
||||
logger.debug("Baloo file indexer resumed")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_safe_python_executable():
|
||||
"""
|
||||
Get a safe Python executable for subprocess calls.
|
||||
@@ -241,7 +266,7 @@ class ProcessManager:
|
||||
pass
|
||||
cleanup_attempts += 1
|
||||
finally:
|
||||
# Always close pipes — unblocks threads blocked on read(1) or iterating stderr
|
||||
# Always close pipes - unblocks threads blocked on read(1) or iterating stderr
|
||||
if self.proc:
|
||||
for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
|
||||
if pipe:
|
||||
|
||||
@@ -91,6 +91,9 @@ class TTWInstallerBackendMixin:
|
||||
except Exception as e:
|
||||
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)
|
||||
return False, f"Error executing TTW_Linux_Installer: {e}"
|
||||
finally:
|
||||
from jackify.shared.paths import cleanup_stale_tmp
|
||||
cleanup_stale_tmp()
|
||||
|
||||
def start_ttw_installation(self, ttw_mpi_path: Path, ttw_output_path: Path, output_file: Path):
|
||||
"""Start TTW installation process (non-blocking). Returns (process, error_message)."""
|
||||
@@ -168,6 +171,8 @@ class TTWInstallerBackendMixin:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
from jackify.shared.paths import cleanup_stale_tmp
|
||||
cleanup_stale_tmp()
|
||||
|
||||
def install_ttw_backend_with_output_stream(self, ttw_mpi_path: Path, ttw_output_path: Path, output_callback=None):
|
||||
"""Install TTW with streaming output (DEPRECATED - use start_ttw_installation instead)."""
|
||||
@@ -251,6 +256,9 @@ class TTWInstallerBackendMixin:
|
||||
except Exception as e:
|
||||
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)
|
||||
return False, f"Error executing TTW_Linux_Installer: {e}"
|
||||
finally:
|
||||
from jackify.shared.paths import cleanup_stale_tmp
|
||||
cleanup_stale_tmp()
|
||||
|
||||
@staticmethod
|
||||
def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str, skip_copy: bool = False) -> bool:
|
||||
@@ -271,7 +279,7 @@ class TTWInstallerBackendMixin:
|
||||
mod_folder_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}" if ttw_version else "[NoDelete] Tale of Two Wastelands"
|
||||
target_mod_dir = mods_dir / mod_folder_name
|
||||
if skip_copy:
|
||||
# TTW was installed directly to target_mod_dir — no copy needed
|
||||
# TTW was installed directly to target_mod_dir - no copy needed
|
||||
logger.info("TTW already at target location, skipping copy: %s", target_mod_dir)
|
||||
else:
|
||||
logger.info("Copying TTW output to %s", target_mod_dir)
|
||||
|
||||
@@ -26,18 +26,21 @@ class WabbajackParser:
|
||||
'Fallout4': 'fallout4',
|
||||
'FalloutNewVegas': 'falloutnv',
|
||||
'Oblivion': 'oblivion',
|
||||
'Skyrim': 'skyrim', # Legacy Skyrim
|
||||
'Fallout3': 'fallout3', # For completeness
|
||||
'SkyrimVR': 'skyrim', # Treat as Skyrim
|
||||
'Fallout4VR': 'fallout4', # Treat as Fallout 4
|
||||
'Enderal': 'enderal', # Enderal: Forgotten Stories
|
||||
'EnderalSpecialEdition': 'enderal', # Enderal SE
|
||||
'Skyrim': 'skyrim',
|
||||
'Fallout3': 'fallout3',
|
||||
'SkyrimVR': 'skyrimvr',
|
||||
'Fallout4VR': 'fallout4vr',
|
||||
'Enderal': 'enderal',
|
||||
'EnderalSpecialEdition': 'enderal',
|
||||
'Cyberpunk2077': 'cp2077',
|
||||
'BaldursGate3': 'bg3',
|
||||
}
|
||||
|
||||
|
||||
# List of supported games in Jackify
|
||||
self.supported_games = [
|
||||
'skyrim', 'fallout4', 'falloutnv', 'fallout3', 'oblivion',
|
||||
'starfield', 'oblivion_remastered', 'enderal'
|
||||
'starfield', 'oblivion_remastered', 'enderal',
|
||||
'skyrimvr', 'fallout4vr', 'bg3',
|
||||
]
|
||||
|
||||
def parse_wabbajack_game_type(self, wabbajack_path: Path) -> Optional[tuple]:
|
||||
@@ -98,6 +101,23 @@ class WabbajackParser:
|
||||
self.logger.error(f"Error parsing .wabbajack file {wabbajack_path}: {e}")
|
||||
return None
|
||||
|
||||
def parse_wabbajack_readme(self, wabbajack_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Extract the readme URL from a .wabbajack file.
|
||||
|
||||
Returns the URL string, or None if not present or unreadable.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(wabbajack_path, 'r') as zip_file:
|
||||
modlist_files = [f for f in zip_file.namelist() if f in ['modlist', 'modlist.json']]
|
||||
if not modlist_files:
|
||||
return None
|
||||
with zip_file.open(modlist_files[0]) as f:
|
||||
data = json.load(f)
|
||||
return data.get('Readme') or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def is_supported_game(self, game_type: str) -> bool:
|
||||
"""
|
||||
Check if a game type is supported by Jackify's post-install configuration.
|
||||
@@ -128,12 +148,16 @@ class WabbajackParser:
|
||||
"""
|
||||
display_names = {
|
||||
'skyrim': 'Skyrim Special Edition',
|
||||
'fallout4': 'Fallout 4',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
'enderal': 'Enderal',
|
||||
'skyrimvr': 'Skyrim VR',
|
||||
'fallout4vr': 'Fallout 4 VR',
|
||||
'cp2077': 'Cyberpunk 2077',
|
||||
'bg3': "Baldur's Gate 3",
|
||||
}
|
||||
return [display_names.get(game, game) for game in self.supported_games]
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ Extracted from wine_utils for file-size and domain separation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import Optional
|
||||
@@ -56,39 +55,6 @@ class WineUtilsConfigMixin:
|
||||
logger.error(f"Error performing additional tasks: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def modlist_specific_steps(modlist: str, appid: str) -> bool:
|
||||
"""Perform modlist-specific configuration steps. Returns True on success."""
|
||||
try:
|
||||
modlist_configs = {
|
||||
"wildlander": ["dotnet48", "dotnet472", "vcrun2019"],
|
||||
"septimus|sigernacollection|licentia|aldrnari|phoenix": ["dotnet48", "dotnet472"],
|
||||
"masterstroke": ["dotnet48", "dotnet472"],
|
||||
"diablo": ["dotnet48", "dotnet472"],
|
||||
"living_skyrim": ["dotnet48", "dotnet472", "dotnet462"],
|
||||
"nolvus": ["dotnet8"]
|
||||
}
|
||||
modlist_lower = modlist.lower().replace(" ", "")
|
||||
if "wildlander" in modlist_lower:
|
||||
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
|
||||
return True
|
||||
for pattern, components in modlist_configs.items():
|
||||
if re.search(pattern.replace("|", "|.*"), modlist_lower):
|
||||
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
|
||||
for component in components:
|
||||
if component == "dotnet8":
|
||||
logger.info("Downloading .NET 8 Runtime")
|
||||
pass
|
||||
else:
|
||||
logger.info(f"Installing {component}...")
|
||||
pass
|
||||
return True
|
||||
logger.debug(f"No specific steps needed for {modlist}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error performing modlist-specific steps: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def fnv_launch_options(game_var: str, compat_data_path: Optional[str], modlist: str) -> bool:
|
||||
"""Set up Fallout New Vegas launch options. Returns True on success."""
|
||||
|
||||
@@ -136,7 +136,7 @@ class WineUtilsProtonMixin:
|
||||
if fallback_path != 'auto':
|
||||
fallback_wine_bin = Path(fallback_path) / "files/bin/wine"
|
||||
if fallback_wine_bin.is_file():
|
||||
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
|
||||
logger.info(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
|
||||
return str(fallback_wine_bin)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -36,7 +36,6 @@ def _get_clean_winetricks_base_env() -> dict:
|
||||
env["PATH"] = path or "/usr/bin:/bin"
|
||||
return env
|
||||
|
||||
|
||||
class WinetricksEnvMixin:
|
||||
"""Mixin providing env build and dependency check for WinetricksHandler.install_wine_components."""
|
||||
|
||||
@@ -54,10 +53,11 @@ class WinetricksEnvMixin:
|
||||
env['WINEDEBUG'] = '-all'
|
||||
env['WINEPREFIX'] = wineprefix
|
||||
env['WINETRICKS_GUI'] = 'none'
|
||||
if 'DISPLAY' in env:
|
||||
env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d'
|
||||
else:
|
||||
env['DISPLAY'] = env.get('DISPLAY', '')
|
||||
env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d'
|
||||
# Preserve the desktop display variables for Step 4. The validated fix
|
||||
# for the blank taskbar popup regression was keeping DISPLAY available.
|
||||
# Do not strip extra desktop activation vars here without a reproduced,
|
||||
# evidence-backed need.
|
||||
|
||||
try:
|
||||
from ..handlers.config_handler import ConfigHandler
|
||||
@@ -243,7 +243,10 @@ class WinetricksEnvMixin:
|
||||
if not found:
|
||||
missing_deps.append(dep_name)
|
||||
if dep_name in bundled_tools_list:
|
||||
self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)")
|
||||
if dep_name == 'aria2c':
|
||||
self.logger.debug(f" {dep_name}: NOT FOUND (optional - curl/wget used if available)")
|
||||
else:
|
||||
self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)")
|
||||
else:
|
||||
self.logger.warning(f" {dep_name}: NOT FOUND (system only - not bundled)")
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ class ModlistContext:
|
||||
"""Context object for modlist operations."""
|
||||
name: str
|
||||
install_dir: Path
|
||||
download_dir: Path
|
||||
game_type: str
|
||||
nexus_api_key: str
|
||||
download_dir: Optional[Path] = None
|
||||
modlist_value: Optional[str] = None
|
||||
modlist_source: Optional[str] = None # 'identifier' or 'file'
|
||||
resolution: Optional[str] = None
|
||||
@@ -29,8 +29,8 @@ class ModlistContext:
|
||||
"""Convert string paths to Path objects."""
|
||||
if isinstance(self.install_dir, str):
|
||||
self.install_dir = Path(self.install_dir)
|
||||
if isinstance(self.download_dir, str):
|
||||
self.download_dir = Path(self.download_dir)
|
||||
if self.download_dir is not None and isinstance(self.download_dir, str):
|
||||
self.download_dir = Path(self.download_dir) if self.download_dir else None
|
||||
if isinstance(self.mo2_exe_path, str):
|
||||
self.mo2_exe_path = Path(self.mo2_exe_path)
|
||||
|
||||
|
||||
@@ -19,66 +19,68 @@ logger = logging.getLogger(__name__)
|
||||
class GameUtilsMixin:
|
||||
"""Mixin for game-related utility operations"""
|
||||
|
||||
def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
|
||||
"""
|
||||
Generate launch options for FNV/Enderal games that require vanilla compatdata.
|
||||
|
||||
Args:
|
||||
special_game_type: "fnv" or "enderal"
|
||||
modlist_install_dir: Directory where the modlist is installed
|
||||
|
||||
Returns:
|
||||
Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
|
||||
"""
|
||||
if not special_game_type or special_game_type not in ["fnv", "enderal"]:
|
||||
return None
|
||||
|
||||
logger.info(f"Generating {special_game_type.upper()} launch options")
|
||||
|
||||
# Map game types to AppIDs
|
||||
appid_map = {"fnv": "22380", "enderal": "976620"}
|
||||
appid = appid_map[special_game_type]
|
||||
|
||||
# Find vanilla game compatdata
|
||||
from ..handlers.path_handler import PathHandler
|
||||
compatdata_path = PathHandler.find_compat_data(appid)
|
||||
if not compatdata_path:
|
||||
logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
|
||||
return None
|
||||
|
||||
# Create STEAM_COMPAT_DATA_PATH string
|
||||
compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
|
||||
|
||||
# Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
|
||||
compat_mounts_str = ""
|
||||
try:
|
||||
all_libs = PathHandler.get_all_steam_library_paths()
|
||||
main_steam_lib_path_obj = PathHandler.find_steam_library()
|
||||
if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
|
||||
main_steam_lib_path = main_steam_lib_path_obj.parent.parent
|
||||
else:
|
||||
main_steam_lib_path = main_steam_lib_path_obj
|
||||
|
||||
mount_paths = []
|
||||
if main_steam_lib_path:
|
||||
main_resolved = main_steam_lib_path.resolve()
|
||||
for lib_path in all_libs:
|
||||
if lib_path.resolve() != main_resolved:
|
||||
mount_paths.append(str(lib_path.resolve()))
|
||||
|
||||
if mount_paths:
|
||||
mount_paths_str = ':'.join(mount_paths)
|
||||
compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
|
||||
logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
|
||||
|
||||
# Combine all launch options
|
||||
launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
|
||||
launch_options = ' '.join(launch_options.split()) # Clean up spacing
|
||||
|
||||
logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
|
||||
return launch_options
|
||||
# TODO post-0.6: remove this method - dead code, never called.
|
||||
# Superseded by registry injection (game paths written directly into the modlist prefix).
|
||||
# def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
|
||||
# """
|
||||
# Generate launch options for FNV/Enderal games that require vanilla compatdata.
|
||||
#
|
||||
# Args:
|
||||
# special_game_type: "fnv" or "enderal"
|
||||
# modlist_install_dir: Directory where the modlist is installed
|
||||
#
|
||||
# Returns:
|
||||
# Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
|
||||
# """
|
||||
# if not special_game_type or special_game_type not in ["fnv", "enderal"]:
|
||||
# return None
|
||||
#
|
||||
# logger.info(f"Generating {special_game_type.upper()} launch options")
|
||||
#
|
||||
# # Map game types to AppIDs
|
||||
# appid_map = {"fnv": "22380", "enderal": "976620"}
|
||||
# appid = appid_map[special_game_type]
|
||||
#
|
||||
# # Find vanilla game compatdata
|
||||
# from ..handlers.path_handler import PathHandler
|
||||
# compatdata_path = PathHandler.find_compat_data(appid)
|
||||
# if not compatdata_path:
|
||||
# logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
|
||||
# return None
|
||||
#
|
||||
# # Create STEAM_COMPAT_DATA_PATH string
|
||||
# compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
|
||||
#
|
||||
# # Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
|
||||
# compat_mounts_str = ""
|
||||
# try:
|
||||
# all_libs = PathHandler.get_all_steam_library_paths()
|
||||
# main_steam_lib_path_obj = PathHandler.find_steam_library()
|
||||
# if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
|
||||
# main_steam_lib_path = main_steam_lib_path_obj.parent.parent
|
||||
# else:
|
||||
# main_steam_lib_path = main_steam_lib_path_obj
|
||||
#
|
||||
# mount_paths = []
|
||||
# if main_steam_lib_path:
|
||||
# main_resolved = main_steam_lib_path.resolve()
|
||||
# for lib_path in all_libs:
|
||||
# if lib_path.resolve() != main_resolved:
|
||||
# mount_paths.append(str(lib_path.resolve()))
|
||||
#
|
||||
# if mount_paths:
|
||||
# mount_paths_str = ':'.join(mount_paths)
|
||||
# compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
|
||||
# logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
|
||||
# except Exception as e:
|
||||
# logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
|
||||
#
|
||||
# # Combine all launch options
|
||||
# launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
|
||||
# launch_options = ' '.join(launch_options.split()) # Clean up spacing
|
||||
#
|
||||
# logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
|
||||
# return launch_options
|
||||
|
||||
def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]:
|
||||
"""Find a Steam game installation path by AppID and common names"""
|
||||
@@ -140,36 +142,90 @@ class GameUtilsMixin:
|
||||
|
||||
return None
|
||||
|
||||
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str):
|
||||
def _detect_skyrim_se_modlist(self, modlist_dir: str) -> bool:
|
||||
"""
|
||||
Return True if modlist_dir is a Skyrim SE (non-VR) modlist.
|
||||
|
||||
Used only to trigger first-launch seeding when special_game_type is None.
|
||||
Other games are not yet confirmed to need this treatment.
|
||||
"""
|
||||
if not modlist_dir:
|
||||
return False
|
||||
try:
|
||||
mo2_ini = Path(modlist_dir) / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
mo2_ini = Path(modlist_dir) / "files" / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
return False
|
||||
content = mo2_ini.read_text(errors='ignore').lower()
|
||||
# Anchor VR check to gameName= to avoid false positives from plugin
|
||||
# setting keys like enable_skyrimVR=false appearing in SE modlists.
|
||||
for _line in content.splitlines():
|
||||
if _line.strip().startswith("gamename="):
|
||||
game_name_value = _line.strip()[len("gamename="):]
|
||||
if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value:
|
||||
return False
|
||||
break
|
||||
return 'skyrim special edition' in content or 'skse64_loader' in content
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not check Skyrim SE detection for {modlist_dir}: {e}")
|
||||
return False
|
||||
|
||||
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str,
|
||||
modlist_dir: Optional[str] = None):
|
||||
"""
|
||||
Pre-create game-specific user directories to prevent first-launch issues.
|
||||
|
||||
Creates both My Documents/My Games and AppData/Local directories for the game.
|
||||
This prevents issues where games fail to create these on first launch under Proton.
|
||||
special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games). For standard
|
||||
games like Skyrim SE that aren't "special" in that sense, modlist_dir is used to
|
||||
detect what directories to seed.
|
||||
"""
|
||||
# Map game types to their directory names
|
||||
# Bethesda-pattern games: same name used for both My Games and AppData/Local
|
||||
game_dir_names = {
|
||||
"skyrim": "Skyrim Special Edition",
|
||||
"skyrimvr": "Skyrim VR",
|
||||
"fnv": "FalloutNV",
|
||||
"fo3": "Fallout3",
|
||||
"fo4": "Fallout4",
|
||||
"fallout4vr": "Fallout4VR",
|
||||
"oblivion": "Oblivion",
|
||||
"oblivion_remastered": "Oblivion Remastered",
|
||||
"enderal": "Enderal Special Edition",
|
||||
"starfield": "Starfield"
|
||||
"starfield": "Starfield",
|
||||
}
|
||||
|
||||
# Get the directory name for this game type
|
||||
game_dir_name = game_dir_names.get(special_game_type)
|
||||
if not game_dir_name:
|
||||
logger.debug(f"No user directory mapping for game type: {special_game_type}")
|
||||
return
|
||||
# Non-Bethesda games: AppData/Local only, with a vendor-namespaced subdirectory
|
||||
game_appdata_only = {
|
||||
"cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"),
|
||||
"bg3": os.path.join("Larian Studios", "Baldur's Gate 3"),
|
||||
}
|
||||
|
||||
# special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games).
|
||||
# Skyrim SE returns None from detect_special_game_type but still needs seeding.
|
||||
game_type = special_game_type
|
||||
if special_game_type is None and modlist_dir and self._detect_skyrim_se_modlist(modlist_dir):
|
||||
game_type = "skyrim"
|
||||
|
||||
base_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser")
|
||||
|
||||
if game_type in game_appdata_only:
|
||||
appdata_dir = os.path.join(base_path, "AppData", "Local", game_appdata_only[game_type])
|
||||
try:
|
||||
os.makedirs(appdata_dir, exist_ok=True)
|
||||
logger.info(f"Created AppData/Local directory: {appdata_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create AppData/Local directory {appdata_dir}: {e}")
|
||||
return
|
||||
|
||||
game_dir_name = game_dir_names.get(game_type)
|
||||
if not game_dir_name:
|
||||
logger.debug(f"No user directory mapping for game type: {game_type}")
|
||||
return
|
||||
|
||||
directories_to_create = [
|
||||
os.path.join(base_path, "Documents", "My Games", game_dir_name),
|
||||
os.path.join(base_path, "AppData", "Local", game_dir_name)
|
||||
os.path.join(base_path, "AppData", "Local", game_dir_name),
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
@@ -184,90 +240,46 @@ class GameUtilsMixin:
|
||||
if created_count > 0:
|
||||
logger.info(f"Created {created_count} user directories for {game_dir_name}")
|
||||
|
||||
def _get_lorerim_preferred_proton(self):
|
||||
"""Get Lorerim's preferred Proton 9 version with specific priority order"""
|
||||
if game_type == "skyrim":
|
||||
self._seed_skyrim_first_launch_files(base_path, game_dir_name)
|
||||
elif game_type == "fo4":
|
||||
self._seed_fo4_first_launch_files(base_path, game_dir_name)
|
||||
elif game_type == "skyrimvr":
|
||||
self._seed_skyrimvr_first_launch_files(base_path, game_dir_name)
|
||||
elif game_type == "fallout4vr":
|
||||
self._seed_fallout4vr_first_launch_files(base_path, game_dir_name)
|
||||
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""Delegate to FileSystemHandler to seed Skyrim first-launch fix files."""
|
||||
try:
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
|
||||
# Get all available Proton versions
|
||||
available_versions = WineUtils.scan_all_proton_versions()
|
||||
|
||||
if not available_versions:
|
||||
logger.warning("No Proton versions found for Lorerim override")
|
||||
return None
|
||||
|
||||
# Priority order for Lorerim:
|
||||
# 1. GEProton9-27 (specific version)
|
||||
# 2. Other GEProton-9 versions (latest first)
|
||||
# 3. Valve Proton 9 (any version)
|
||||
|
||||
preferred_candidates = []
|
||||
|
||||
for version in available_versions:
|
||||
version_name = version['name']
|
||||
|
||||
# Priority 1: GEProton9-27 specifically
|
||||
if version_name == 'GE-Proton9-27':
|
||||
logger.info(f"Lorerim: Found preferred GE-Proton9-27")
|
||||
return version_name
|
||||
|
||||
# Priority 2: Other GE-Proton 9 versions
|
||||
elif version_name.startswith('GE-Proton9-'):
|
||||
preferred_candidates.append(('ge_proton_9', version_name, version))
|
||||
|
||||
# Priority 3: Valve Proton 9
|
||||
elif 'Proton 9' in version_name:
|
||||
preferred_candidates.append(('valve_proton_9', version_name, version))
|
||||
|
||||
# Return best candidate if any found
|
||||
if preferred_candidates:
|
||||
# Sort by priority (GE-Proton first, then by name for latest)
|
||||
preferred_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||
best_candidate = preferred_candidates[0]
|
||||
logger.info(f"Lorerim: Selected {best_candidate[1]} as best Proton 9 option")
|
||||
return best_candidate[1]
|
||||
|
||||
logger.warning("Lorerim: No suitable Proton 9 versions found, will use user settings")
|
||||
return None
|
||||
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fsh = FileSystemHandler()
|
||||
fsh._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Error detecting Lorerim Proton preference: {e}")
|
||||
return None
|
||||
logger.warning(f"Could not seed Skyrim first-launch files: {e}")
|
||||
|
||||
def _store_proton_override_notification(self, modlist_name: str, proton_version: str):
|
||||
"""Store Proton override information for end-of-install notification"""
|
||||
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""Delegate to FileSystemHandler to seed Fallout 4 first-launch fix files."""
|
||||
try:
|
||||
# Store override info for later display
|
||||
if not hasattr(self, '_proton_overrides'):
|
||||
self._proton_overrides = []
|
||||
|
||||
self._proton_overrides.append({
|
||||
'modlist': modlist_name,
|
||||
'proton_version': proton_version,
|
||||
'reason': f'{modlist_name} requires Proton 9 for optimal compatibility'
|
||||
})
|
||||
|
||||
logger.debug(f"Stored Proton override notification: {modlist_name} → {proton_version}")
|
||||
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fsh = FileSystemHandler()
|
||||
fsh._seed_fo4_first_launch_files(prefix_user, docs_dir_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store Proton override notification: {e}")
|
||||
logger.warning(f"Could not seed FO4 first-launch files: {e}")
|
||||
|
||||
def _show_proton_override_notification(self, progress_callback=None):
|
||||
"""Display any Proton override notifications to the user"""
|
||||
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""Delegate to FileSystemHandler to seed Skyrim VR first-launch fix files."""
|
||||
try:
|
||||
if hasattr(self, '_proton_overrides') and self._proton_overrides:
|
||||
for override in self._proton_overrides:
|
||||
notification_msg = f"PROTON OVERRIDE: {override['modlist']} configured to use {override['proton_version']} for optimal compatibility"
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("")
|
||||
progress_callback(f"{self._get_progress_timestamp()} {notification_msg}")
|
||||
|
||||
logger.info(notification_msg)
|
||||
|
||||
# Clear notifications after display
|
||||
self._proton_overrides = []
|
||||
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fsh = FileSystemHandler()
|
||||
fsh._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to show Proton override notification: {e}")
|
||||
logger.warning(f"Could not seed SkyrimVR first-launch files: {e}")
|
||||
|
||||
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""Delegate to FileSystemHandler to seed Fallout 4 VR first-launch fix files."""
|
||||
try:
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fsh = FileSystemHandler()
|
||||
fsh._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not seed FO4VR first-launch files: {e}")
|
||||
|
||||
@@ -20,23 +20,6 @@ class ProtonOperationsMixin:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
|
||||
# Check for Lorerim-specific Proton override first
|
||||
modlist_normalized = modlist_name.lower().replace(" ", "") if modlist_name else ""
|
||||
if modlist_normalized == 'lorerim':
|
||||
lorerim_proton = self._get_lorerim_preferred_proton()
|
||||
if lorerim_proton:
|
||||
logger.info(f"Lorerim detected: Using {lorerim_proton} instead of user settings")
|
||||
self._store_proton_override_notification("Lorerim", lorerim_proton)
|
||||
return lorerim_proton
|
||||
|
||||
# Check for Lost Legacy-specific Proton override (needs Proton 9 for ENB compatibility)
|
||||
if modlist_normalized == 'lostlegacy':
|
||||
lostlegacy_proton = self._get_lorerim_preferred_proton() # Use same logic as Lorerim
|
||||
if lostlegacy_proton:
|
||||
logger.info(f"Lost Legacy detected: Using {lostlegacy_proton} instead of user settings (ENB compatibility)")
|
||||
self._store_proton_override_notification("Lost Legacy", lostlegacy_proton)
|
||||
return lostlegacy_proton
|
||||
|
||||
config_handler = ConfigHandler()
|
||||
user_proton_path = config_handler.get_game_proton_path()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Registry operations mixin for AutomatedPrefixService."""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -74,7 +75,7 @@ class RegistryOperationsMixin:
|
||||
def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str):
|
||||
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists.
|
||||
|
||||
Direct file editing is preferred over `wine reg add` — faster, no Wine
|
||||
Direct file editing is preferred over `wine reg add` - faster, no Wine
|
||||
process overhead, and works even when Proton isn't on PATH. Falls back
|
||||
to subprocess wine reg add when the reg files haven't been created yet.
|
||||
"""
|
||||
@@ -91,10 +92,12 @@ class RegistryOperationsMixin:
|
||||
|
||||
fix1 = fix2 = False
|
||||
|
||||
# Targeted per-exe override for SkyrimSE.exe only - see modlist_wine_ops.py
|
||||
# for rationale. Global DllOverrides entry breaks .NET 9/10 bootstrap.
|
||||
if os.path.exists(user_reg):
|
||||
fix1 = self._reg_set_value(
|
||||
user_reg,
|
||||
"[Software\\\\Wine\\\\DllOverrides]",
|
||||
"[Software\\\\Wine\\\\AppDefaults\\\\SkyrimSE.exe\\\\DllOverrides]",
|
||||
'"*mscoree"',
|
||||
'"native"',
|
||||
)
|
||||
@@ -123,7 +126,7 @@ class RegistryOperationsMixin:
|
||||
|
||||
r1 = subprocess.run(
|
||||
[wine_binary, 'reg', 'add',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides',
|
||||
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'],
|
||||
env=env, capture_output=True, text=True, errors='replace',
|
||||
)
|
||||
@@ -145,6 +148,53 @@ class RegistryOperationsMixin:
|
||||
logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
|
||||
return False
|
||||
|
||||
def _apply_cp2077_dll_overrides(self, modlist_compatdata_path: str) -> bool:
|
||||
"""Write CP2077 DLL overrides directly into the prefix user.reg.
|
||||
|
||||
MO2 on Linux launches each executable through a separate Proton invocation,
|
||||
so WINEDLLOVERRIDES set in Steam launch options is not inherited by the game
|
||||
process. Writing the overrides into user.reg ensures they are always applied
|
||||
regardless of how the process is started.
|
||||
|
||||
version and winmm are the entry-point DLLs for CET and Red4ext respectively.
|
||||
Without native,builtin for both, neither mod framework can inject into the
|
||||
game process and CP2077 exits immediately.
|
||||
"""
|
||||
try:
|
||||
user_reg = os.path.join(modlist_compatdata_path, "pfx", "user.reg")
|
||||
if not os.path.exists(user_reg):
|
||||
logger.warning("user.reg not found, cannot apply CP2077 DLL overrides")
|
||||
return False
|
||||
|
||||
section = "[Software\\\\Wine\\\\DllOverrides]"
|
||||
overrides = [
|
||||
('"version"', '"native,builtin"'),
|
||||
('"winmm"', '"native,builtin"'),
|
||||
]
|
||||
for key, val in overrides:
|
||||
self._reg_set_value(user_reg, section, key, val)
|
||||
|
||||
logger.info("Applied CP2077 DLL overrides (version, winmm) to prefix registry")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply CP2077 DLL overrides: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _wow64_counterpart(section: str) -> str:
|
||||
"""Return the Wow6432Node counterpart for a registry section, or vice versa.
|
||||
|
||||
NaK writes both paths for every game so both 32-bit and 64-bit lookups
|
||||
resolve correctly regardless of the calling process's bitness.
|
||||
"""
|
||||
low = section.lower()
|
||||
if "wow6432node" in low:
|
||||
# Strip Wow6432Node to get the 64-bit path
|
||||
return re.sub(r'(?i)wow6432node\\\\', '', section)
|
||||
else:
|
||||
# Insert Wow6432Node after the opening [Software\\
|
||||
return re.sub(r'(?i)(\[Software\\\\)', r'\1Wow6432Node\\\\', section)
|
||||
|
||||
def _reg_set_value(self, reg_path: str, section: str, key: str, value: str) -> bool:
|
||||
"""Set or add a key=value pair in a Wine .reg text file."""
|
||||
try:
|
||||
@@ -319,19 +369,19 @@ class RegistryOperationsMixin:
|
||||
"name": "Fallout New Vegas",
|
||||
"common_names": ["Fallout New Vegas", "FalloutNV"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]",
|
||||
"path_key": "Installed Path",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"22300": { # Fallout 3 AppID
|
||||
"name": "Fallout 3",
|
||||
"common_names": ["Fallout 3", "Fallout3", "Fallout 3 GOTY"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
|
||||
"path_key": "Installed Path",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"22370": { # Fallout 3 GOTY AppID alias
|
||||
"name": "Fallout 3",
|
||||
"common_names": ["Fallout 3 GOTY", "Fallout 3"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
|
||||
"path_key": "Installed Path",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"976620": { # Enderal Special Edition AppID
|
||||
"name": "Enderal",
|
||||
@@ -339,6 +389,72 @@ class RegistryOperationsMixin:
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"1091500": { # Cyberpunk 2077 AppID
|
||||
"name": "Cyberpunk 2077",
|
||||
"common_names": ["Cyberpunk 2077"],
|
||||
"registry_section": "[Software\\\\CD Projekt Red\\\\Cyberpunk 2077]",
|
||||
"path_key": "InstallFolder",
|
||||
},
|
||||
"1086940": { # Baldur's Gate 3 AppID
|
||||
"name": "Baldur's Gate 3",
|
||||
"common_names": ["Baldur's Gate 3", "BaldursGate3"],
|
||||
"registry_section": "[Software\\\\Larian Studios\\\\Baldur's Gate 3]",
|
||||
"path_key": "InstallDir",
|
||||
},
|
||||
"611670": { # Skyrim VR AppID (64-bit, no Wow6432Node)
|
||||
"name": "Skyrim VR",
|
||||
"common_names": ["Skyrim VR", "SkyrimVR"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim VR]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"611660": { # Fallout 4 VR AppID (64-bit, no Wow6432Node)
|
||||
"name": "Fallout 4 VR",
|
||||
"common_names": ["Fallout 4 VR", "Fallout4VR"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout 4 VR]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"22330": { # Oblivion AppID
|
||||
"name": "Oblivion",
|
||||
"common_names": ["Oblivion", "Elder Scrolls IV Oblivion"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\oblivion]",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"1716740": { # Starfield AppID (64-bit, no Wow6432Node)
|
||||
"name": "Starfield",
|
||||
"common_names": ["Starfield"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Starfield]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"489830": { # Skyrim Special Edition AppID (64-bit, no Wow6432Node)
|
||||
"name": "Skyrim Special Edition",
|
||||
"common_names": ["Skyrim Special Edition", "SkyrimSE", "Skyrim Anniversary Edition"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim Special Edition]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"377160": { # Fallout 4 AppID (64-bit, no Wow6432Node)
|
||||
"name": "Fallout 4",
|
||||
"common_names": ["Fallout 4", "Fallout4"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout4]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"22320": { # Morrowind AppID (32-bit, Wow6432Node)
|
||||
"name": "Morrowind",
|
||||
"common_names": ["Morrowind", "Elder Scrolls III Morrowind"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\morrowind]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"292030": { # The Witcher 3 AppID (64-bit, no Wow6432Node)
|
||||
"name": "The Witcher 3",
|
||||
"common_names": ["The Witcher 3", "Witcher 3", "The Witcher 3 Wild Hunt"],
|
||||
"registry_section": "[Software\\\\CD Projekt Red\\\\The Witcher 3]",
|
||||
"path_key": "InstallFolder",
|
||||
},
|
||||
"2623190": { # Oblivion Remastered AppID (64-bit UE5, no Wow6432Node)
|
||||
"name": "Oblivion Remastered",
|
||||
"common_names": ["Oblivion Remastered", "OblivionRemastered"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Oblivion Remastered]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
}
|
||||
|
||||
pfx_path = Path(modlist_compatdata_path) / "pfx"
|
||||
@@ -359,24 +475,22 @@ class RegistryOperationsMixin:
|
||||
game_dir_name = Path(game_path).name
|
||||
canonical_win_path = f"C:\\Program Files (x86)\\Steam\\steamapps\\common\\{game_dir_name}"
|
||||
wine_val = canonical_win_path.replace("\\", "\\\\") + "\\\\"
|
||||
success = self._reg_set_value(
|
||||
system_reg_path,
|
||||
config["registry_section"],
|
||||
f'"{config["path_key"]}"',
|
||||
f'"{wine_val}"',
|
||||
)
|
||||
key = f'"{config["path_key"]}"'
|
||||
val = f'"{wine_val}"'
|
||||
success = self._reg_set_value(system_reg_path, config["registry_section"], key, val)
|
||||
self._reg_set_value(system_reg_path, self._wow64_counterpart(config["registry_section"]), key, val)
|
||||
if success:
|
||||
logger.info(f"Registry set to canonical path for {config['name']}: {canonical_win_path}")
|
||||
else:
|
||||
logger.warning(f"Failed to set canonical registry path for {config['name']}")
|
||||
else:
|
||||
# Symlink failed — fall back to writing the real Z:/D: path
|
||||
# Symlink failed - fall back to writing the real Z:/D: path
|
||||
logger.warning(f"Symlink failed for {config['name']}, writing real path to registry")
|
||||
success = self._update_registry_path(
|
||||
system_reg_path,
|
||||
config["registry_section"],
|
||||
config["path_key"],
|
||||
game_path
|
||||
system_reg_path, config["registry_section"], config["path_key"], game_path
|
||||
)
|
||||
self._update_registry_path(
|
||||
system_reg_path, self._wow64_counterpart(config["registry_section"]), config["path_key"], game_path
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Updated registry entry for {config['name']} (real path fallback)")
|
||||
|
||||
@@ -38,25 +38,29 @@ class ShortcutOperationsMixin(AutomatedPrefixShortcutsCleanupMixin):
|
||||
# Initialize native Steam service
|
||||
steam_service = NativeSteamService()
|
||||
|
||||
# Use custom launch options if provided, otherwise generate default
|
||||
# Always compute STEAM_COMPAT_MOUNTS; custom_launch_options replaces %command% but
|
||||
# still needs mounts so game assets on other drives are reachable inside the prefix.
|
||||
mounts_prefix = ""
|
||||
try:
|
||||
from ..handlers.path_handler import PathHandler
|
||||
path_handler = PathHandler()
|
||||
mount_paths = path_handler.get_steam_compat_mount_paths(
|
||||
install_dir=modlist_install_dir, download_dir=download_dir
|
||||
)
|
||||
if mount_paths:
|
||||
mounts_prefix = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}"'
|
||||
logger.info(f"Generated STEAM_COMPAT_MOUNTS: {mounts_prefix}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS: {e}")
|
||||
|
||||
if custom_launch_options:
|
||||
launch_options = custom_launch_options
|
||||
logger.info(f"Using pre-generated launch options: {launch_options}")
|
||||
launch_options = f"{mounts_prefix} {custom_launch_options}".strip() if mounts_prefix else custom_launch_options
|
||||
logger.info(f"Launch options (custom + mounts): {launch_options}")
|
||||
elif mounts_prefix:
|
||||
launch_options = f'{mounts_prefix} %command%'
|
||||
logger.info(f"Launch options (mounts only): {launch_options}")
|
||||
else:
|
||||
# Generate STEAM_COMPAT_MOUNTS including install and download mountpoints
|
||||
launch_options = "%command%"
|
||||
try:
|
||||
from ..handlers.path_handler import PathHandler
|
||||
path_handler = PathHandler()
|
||||
mount_paths = path_handler.get_steam_compat_mount_paths(
|
||||
install_dir=modlist_install_dir, download_dir=download_dir
|
||||
)
|
||||
if mount_paths:
|
||||
launch_options = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}" %command%'
|
||||
logger.info(f"Generated launch options with mounts: {launch_options}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS, using default: {e}")
|
||||
launch_options = "%command%"
|
||||
|
||||
# Get user's preferred Proton version (with Lorerim-specific override)
|
||||
proton_version = self._get_user_proton_version(shortcut_name)
|
||||
|
||||
@@ -178,10 +178,20 @@ class WorkflowMixin:
|
||||
modlist_handler = ModlistHandler()
|
||||
special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir)
|
||||
|
||||
# No launch options needed - FNV, FO3 and Enderal use registry injection
|
||||
custom_launch_options = None
|
||||
if special_game_type in ["fnv", "fo3", "enderal"]:
|
||||
logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist")
|
||||
elif special_game_type == "cp2077":
|
||||
logger.info("Cyberpunk 2077 modlist detected - setting WINEDLLOVERRIDES for Red4ext/CET")
|
||||
# version=n,b overrides d3d version detection for REDmod; winmm=n,b required for CET
|
||||
custom_launch_options = 'WINEDLLOVERRIDES="version=n,b;winmm=n,b" %command%'
|
||||
elif special_game_type == "bg3":
|
||||
logger.info("Baldur's Gate 3 modlist detected")
|
||||
logger.warning("BG3 modlists require Rootbuilder in COPY mode - verify this in MO2 plugin settings")
|
||||
elif special_game_type in ["skyrimvr", "fallout4vr"]:
|
||||
game_label = "Skyrim VR" if special_game_type == "skyrimvr" else "Fallout 4 VR"
|
||||
logger.warning("%s modlist detected - SteamVR must be installed and running for this modlist to work", game_label)
|
||||
logger.warning("%s modlists use Rootbuilder for game root files - ensure Rootbuilder is set to COPY mode in MO2 plugin settings", game_label)
|
||||
else:
|
||||
logger.debug("Standard modlist - no special game handling needed")
|
||||
|
||||
@@ -202,6 +212,31 @@ class WorkflowMixin:
|
||||
if progress_callback:
|
||||
progress_callback(f"{self._get_progress_timestamp()} Steam shut down")
|
||||
|
||||
# Pre-fetch SteamGridDB artwork before shortcut creation so the icon field in
|
||||
# shortcuts.vdf is populated at write time. Steam caches the icon on first read
|
||||
# after restart; setting it after the fact has no effect.
|
||||
steamicons_dir = Path(modlist_install_dir) / "SteamIcons"
|
||||
if not steamicons_dir.is_dir():
|
||||
from ..services.steamgriddb_service import detect_game_type_from_modlist
|
||||
_prefetch_game_type = detect_game_type_from_modlist(modlist_install_dir)
|
||||
if _prefetch_game_type:
|
||||
try:
|
||||
from ..services.steamgriddb_service import fetch_artwork
|
||||
steamicons_dir.mkdir(parents=True, exist_ok=True)
|
||||
count = fetch_artwork(_prefetch_game_type, steamicons_dir)
|
||||
if count == 0:
|
||||
steamicons_dir.rmdir()
|
||||
logger.debug("SteamGridDB pre-fetch returned no images")
|
||||
else:
|
||||
logger.info(f"Pre-fetched {count} SteamGridDB images to {steamicons_dir}")
|
||||
except Exception as e:
|
||||
logger.debug(f"SteamGridDB pre-fetch failed: {e}")
|
||||
try:
|
||||
if steamicons_dir.is_dir() and not any(steamicons_dir.iterdir()):
|
||||
steamicons_dir.rmdir()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 1: Create shortcut with native Steam service (Steam is now shut down)
|
||||
logger.info("Step 1: Creating shortcut with native Steam service")
|
||||
# Create shortcut using native Steam service with special game launch options
|
||||
@@ -222,9 +257,9 @@ class WorkflowMixin:
|
||||
from ..handlers.modlist_handler import ModlistHandler
|
||||
modlist_handler = ModlistHandler()
|
||||
modlist_handler.set_steam_grid_images(str(appid), modlist_install_dir)
|
||||
logger.info(f"Applied Steam artwork for shortcut '{shortcut_name}' (AppID: {appid})")
|
||||
logger.info(f"Steam artwork applied for shortcut '{shortcut_name}' (AppID: {appid})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to apply Steam artwork: {e}")
|
||||
logger.warning(f"Steam artwork application failed: {e}")
|
||||
|
||||
# Step 2: Start Steam (if auto_restart enabled)
|
||||
logger.info("Step 2: auto_restart=%s", auto_restart)
|
||||
@@ -243,6 +278,7 @@ class WorkflowMixin:
|
||||
logger.info("Step 2 completed: Steam started")
|
||||
if progress_callback:
|
||||
progress_callback(f"{self._get_progress_timestamp()} Steam started successfully")
|
||||
progress_callback("[Jackify] Steam restart complete")
|
||||
else:
|
||||
logger.info("Step 2 skipped: Auto-restart disabled by user")
|
||||
if progress_callback:
|
||||
@@ -287,6 +323,15 @@ class WorkflowMixin:
|
||||
self._inject_game_registry_entries(str(prefix_path), special_game_type)
|
||||
else:
|
||||
logger.warning("Could not find prefix path for registry injection")
|
||||
elif special_game_type == "cp2077":
|
||||
logger.info("Step 5: Applying CP2077 DLL overrides to prefix registry")
|
||||
if progress_callback:
|
||||
progress_callback(f"{self._get_progress_timestamp()} Configuring CP2077 mod framework DLL overrides...")
|
||||
|
||||
if prefix_path:
|
||||
self._apply_cp2077_dll_overrides(str(prefix_path))
|
||||
else:
|
||||
logger.warning("Could not find prefix path for CP2077 DLL override injection")
|
||||
else:
|
||||
logger.info("Step 5: Skipping registry injection for standard modlist")
|
||||
|
||||
@@ -296,18 +341,18 @@ class WorkflowMixin:
|
||||
progress_callback(f"{self._get_progress_timestamp()} Creating game user directories...")
|
||||
|
||||
if prefix_path:
|
||||
self._create_game_user_directories(str(prefix_path), special_game_type)
|
||||
self._create_game_user_directories(str(prefix_path), special_game_type, modlist_install_dir)
|
||||
else:
|
||||
logger.warning("Could not find prefix path for directory creation")
|
||||
|
||||
|
||||
|
||||
|
||||
last_timestamp = self._get_progress_timestamp()
|
||||
logger.info(f" Working workflow completed successfully! AppID: {appid}, Prefix: {prefix_path}")
|
||||
if progress_callback:
|
||||
progress_callback(f"{last_timestamp} Steam integration complete")
|
||||
progress_callback("") # Blank line after Steam integration complete
|
||||
|
||||
# Show Proton override notification if applicable
|
||||
self._show_proton_override_notification(progress_callback)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("") # Extra blank line to span across Configuration Summary
|
||||
|
||||
@@ -4,6 +4,7 @@ list of pending manual download items by lax filename comparison.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
@@ -106,6 +107,14 @@ class DownloadWatcherService:
|
||||
logger.debug(f"Candidate dot-normalized match: {path.name} -> {expected_name}")
|
||||
self._debounce_and_emit(path, item)
|
||||
return
|
||||
# Some modlist metadata stores filenames with a leading numeric prefix
|
||||
# (e.g. "1_filename.zip") that is absent from the browser-saved file.
|
||||
for expected_name, item in self._pending_exact:
|
||||
stripped = re.sub(r'^\d+_', '', expected_name)
|
||||
if stripped != expected_name and stripped == candidate_name:
|
||||
logger.debug(f"Candidate numeric-prefix match: {path.name} -> {expected_name}")
|
||||
self._debounce_and_emit(path, item)
|
||||
return
|
||||
|
||||
def _debounce_and_emit(self, path: Path, item: dict) -> None:
|
||||
def _wait_and_emit():
|
||||
|
||||
@@ -221,7 +221,7 @@ class FileValidatorService:
|
||||
|
||||
def _validate(self, file_path: Path, expected_hash: str) -> ValidationResult:
|
||||
try:
|
||||
# No expected hash — accept by filename match alone, just move the file.
|
||||
# No expected hash - accept by filename match alone, just move the file.
|
||||
if not (expected_hash or "").strip():
|
||||
return ValidationResult(matches=True, computed_hash=None, file_path=file_path)
|
||||
h = xxhash.xxh64() if xxhash else _XXH64Fallback()
|
||||
|
||||
@@ -22,7 +22,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
STATUS = Literal["pending", "browser_opened", "validating", "complete", "deferred", "skipped", "error"]
|
||||
|
||||
_STATE_FILE = Path.home() / '.local' / 'share' / 'jackify' / 'manual_download_state.json'
|
||||
def _get_state_file() -> Path:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
return get_jackify_data_dir() / 'manual_download_state.json'
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -223,7 +224,7 @@ class ManualDownloadManagerRuntimeMixin:
|
||||
item_to_notify = item
|
||||
completed_now = True
|
||||
else:
|
||||
# Hash mismatch or validation error — revert to pending so the
|
||||
# Hash mismatch or validation error - revert to pending so the
|
||||
# sliding window can re-open a browser tab and the watcher can
|
||||
# re-validate if the user downloads the correct file.
|
||||
item.status = 'pending'
|
||||
@@ -380,6 +381,13 @@ class ManualDownloadManagerRuntimeMixin:
|
||||
stripped = name.lower().lstrip('.')
|
||||
if stripped != name.lower():
|
||||
exact = exact_map.get(stripped)
|
||||
if exact is None:
|
||||
# Numeric prefix normalization: engine may store filenames with a
|
||||
# leading numeric prefix (e.g. "1_filename.zip") absent from the
|
||||
# browser-saved file.
|
||||
stripped_num = re.sub(r'^\d+_', '', name.lower())
|
||||
if stripped_num != name.lower():
|
||||
exact = exact_map.get(stripped_num)
|
||||
if exact is None or exact in used_paths:
|
||||
continue
|
||||
used_paths.add(exact)
|
||||
|
||||
@@ -91,9 +91,7 @@ class ModlistGalleryService:
|
||||
return metadata
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching modlist metadata: {e}")
|
||||
print("Falling back to cached metadata (may be outdated)")
|
||||
# Fall back to cache if network/engine fails
|
||||
logger.warning("Error fetching modlist metadata: %s - falling back to cache", e)
|
||||
return self._load_from_cache()
|
||||
|
||||
def _fetch_from_engine(
|
||||
@@ -164,7 +162,7 @@ class ModlistGalleryService:
|
||||
data = json.load(f)
|
||||
return parse_modlist_metadata_response(data)
|
||||
except Exception as e:
|
||||
print(f"Error loading cache: {e}")
|
||||
logger.warning("Error loading metadata cache: %s", e)
|
||||
return None
|
||||
|
||||
def _save_to_cache(self, metadata: ModlistMetadataResponse):
|
||||
@@ -182,7 +180,7 @@ class ModlistGalleryService:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving cache: {e}")
|
||||
logger.warning("Error saving metadata cache: %s", e)
|
||||
|
||||
def _metadata_to_dict(self, metadata: ModlistMetadata) -> dict:
|
||||
"""Convert ModlistMetadata to dict for JSON serialization"""
|
||||
@@ -306,7 +304,7 @@ class ModlistGalleryService:
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
print(f"Error downloading images: {e}")
|
||||
logger.warning("Error downloading gallery images: %s", e)
|
||||
return False
|
||||
|
||||
def get_cached_image_path(self, metadata: ModlistMetadata, size: str = "large") -> Optional[Path]:
|
||||
|
||||
@@ -103,10 +103,22 @@ class ModlistService(ModlistServiceInstallationMixin):
|
||||
|
||||
elif game_type_lower == 'enderal':
|
||||
raw_modlists = [m for m in raw_modlists if 'enderal' in m.get('game', '').lower()]
|
||||
|
||||
|
||||
elif game_type_lower == 'skyrimvr':
|
||||
raw_modlists = [m for m in raw_modlists if 'skyrim vr' in m.get('game', '').lower()]
|
||||
|
||||
elif game_type_lower == 'fallout4vr':
|
||||
raw_modlists = [m for m in raw_modlists if 'fallout 4 vr' in m.get('game', '').lower()]
|
||||
|
||||
elif game_type_lower == 'cp2077':
|
||||
raw_modlists = [m for m in raw_modlists if 'cyberpunk' in m.get('game', '').lower()]
|
||||
|
||||
elif game_type_lower == 'bg3':
|
||||
raw_modlists = [m for m in raw_modlists if "baldur" in m.get('game', '').lower()]
|
||||
|
||||
elif game_type_lower == 'other':
|
||||
# Exclude all main category games to show only "Other" games
|
||||
main_category_keywords = ['skyrim', 'fallout 4', 'fallout new vegas', 'oblivion', 'starfield', 'enderal']
|
||||
main_category_keywords = ['skyrim', 'fallout 4', 'fallout new vegas', 'oblivion', 'starfield', 'enderal', 'cyberpunk', "baldur's gate", 'skyrim vr', 'fallout 4 vr']
|
||||
def is_main_category(game_name):
|
||||
game_lower = game_name.lower()
|
||||
return any(keyword in game_lower for keyword in main_category_keywords)
|
||||
@@ -257,6 +269,8 @@ class ModlistService(ModlistServiceInstallationMixin):
|
||||
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
|
||||
original_stdout = None
|
||||
|
||||
from ..handlers.subprocess_utils import suspend_baloo, resume_baloo
|
||||
suspend_baloo()
|
||||
try:
|
||||
# Force GUI mode to prevent input prompts
|
||||
os.environ['JACKIFY_GUI_MODE'] = '1'
|
||||
@@ -344,6 +358,7 @@ class ModlistService(ModlistServiceInstallationMixin):
|
||||
return success
|
||||
|
||||
finally:
|
||||
resume_baloo()
|
||||
# Always restore stdout and environment
|
||||
if original_stdout:
|
||||
sys.stdout = original_stdout
|
||||
|
||||
@@ -59,11 +59,16 @@ class ModlistServiceInstallationMixin:
|
||||
logger.error("Discovery phase failed or was cancelled")
|
||||
return False
|
||||
|
||||
success = self._run_installation_only(
|
||||
confirmed_context,
|
||||
progress_callback=progress_callback,
|
||||
output_callback=output_callback
|
||||
)
|
||||
from ..handlers.subprocess_utils import suspend_baloo, resume_baloo
|
||||
suspend_baloo()
|
||||
try:
|
||||
success = self._run_installation_only(
|
||||
confirmed_context,
|
||||
progress_callback=progress_callback,
|
||||
output_callback=output_callback
|
||||
)
|
||||
finally:
|
||||
resume_baloo()
|
||||
|
||||
if success:
|
||||
logger.info("Modlist installation completed successfully (configuration done separately)")
|
||||
@@ -146,13 +151,16 @@ class ModlistServiceInstallationMixin:
|
||||
cmd += ['-m', context['machineid']]
|
||||
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
||||
|
||||
writeback_path = str(auth_service.get_token_writeback_path())
|
||||
original_env_values = {
|
||||
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
|
||||
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
|
||||
'JACKIFY_TOKEN_WRITEBACK': os.environ.get('JACKIFY_TOKEN_WRITEBACK'),
|
||||
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
|
||||
}
|
||||
|
||||
try:
|
||||
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
|
||||
if oauth_info:
|
||||
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
|
||||
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
|
||||
@@ -199,9 +207,10 @@ class ModlistServiceInstallationMixin:
|
||||
except (OSError, BrokenPipeError):
|
||||
return False
|
||||
|
||||
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename
|
||||
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename, is_creation_kit_missing_error
|
||||
import json as _json
|
||||
_cc_filename = None
|
||||
_ck_missing = False
|
||||
_pending_manual: list = []
|
||||
buffer = b''
|
||||
while True:
|
||||
@@ -263,6 +272,8 @@ class ModlistServiceInstallationMixin:
|
||||
output_callback(decoded)
|
||||
if _cc_filename is None and is_cc_content_error(decoded):
|
||||
_cc_filename = extract_cc_filename(decoded) or ""
|
||||
if not _ck_missing and is_creation_kit_missing_error(decoded):
|
||||
_ck_missing = True
|
||||
|
||||
if buffer:
|
||||
line = buffer.decode('utf-8', errors='replace')
|
||||
@@ -271,8 +282,11 @@ class ModlistServiceInstallationMixin:
|
||||
output_callback(decoded)
|
||||
if _cc_filename is None and is_cc_content_error(decoded):
|
||||
_cc_filename = extract_cc_filename(decoded) or ""
|
||||
if not _ck_missing and is_creation_kit_missing_error(decoded):
|
||||
_ck_missing = True
|
||||
|
||||
proc.wait()
|
||||
auth_service.apply_token_writeback(writeback_path)
|
||||
if proc.returncode != 0:
|
||||
if output_callback:
|
||||
output_callback(f"Jackify Install Engine exited with code {proc.returncode}.")
|
||||
@@ -285,6 +299,16 @@ class ModlistServiceInstallationMixin:
|
||||
output_callback(" - If specific files are still missing, search for and download them from the Creations menu.")
|
||||
output_callback(" - If problems persist, uninstall and reinstall Skyrim, then launch once to trigger the AE download.")
|
||||
output_callback(" - Note: Skyrim AE via Steam Family Sharing does not transfer DLC content.")
|
||||
if _ck_missing and output_callback:
|
||||
output_callback("")
|
||||
output_callback("[WARN] Creation Kit Files Missing")
|
||||
output_callback(" This modlist requires the Skyrim Special Edition Creation Kit.")
|
||||
output_callback(" - In Steam, search for 'Skyrim Special Edition: Creation Kit' and install it.")
|
||||
output_callback(" - Right-click it in Steam > Properties > Compatibility and set a Proton version.")
|
||||
output_callback(" - Click Play to launch the Creation Kit.")
|
||||
output_callback(" - When asked whether to unzip Scripts.zip, select NO.")
|
||||
output_callback(" - Once the Creation Kit opens successfully, close it.")
|
||||
output_callback(" - Re-run the modlist install in Jackify.")
|
||||
return False
|
||||
if output_callback:
|
||||
output_callback("Installation completed successfully")
|
||||
|
||||
@@ -6,6 +6,7 @@ Unified service for Nexus authentication using OAuth or API key fallback
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, Tuple
|
||||
from .nexus_oauth_service import NexusOAuthService
|
||||
from ..handlers.oauth_token_handler import OAuthTokenHandler
|
||||
@@ -288,6 +289,41 @@ class NexusAuthService:
|
||||
logger.warning("No authentication available for engine")
|
||||
return (None, None)
|
||||
|
||||
def get_token_writeback_path(self) -> 'Path':
|
||||
"""Return a PID-unique path where the engine should write back refreshed tokens."""
|
||||
from pathlib import Path
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
return get_jackify_data_dir() / f"oauth_writeback_{os.getpid()}.json"
|
||||
|
||||
def apply_token_writeback(self, writeback_path) -> bool:
|
||||
"""
|
||||
Read engine-written token writeback file and update local token store.
|
||||
Called after engine process exits. No-op if file does not exist (engine not yet
|
||||
supporting writeback, or API key auth was used).
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
path = Path(writeback_path)
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
oauth = data.get('oauth', {})
|
||||
if oauth.get('access_token') and oauth.get('refresh_token'):
|
||||
self.token_handler.save_token({'oauth': oauth})
|
||||
logger.info("Applied OAuth token writeback from engine - refresh token rotation preserved")
|
||||
return True
|
||||
logger.debug("Token writeback file present but contains no usable OAuth data")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("Failed to apply token writeback: %s", e)
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def clear_all_auth(self) -> bool:
|
||||
"""
|
||||
Clear all authentication (both OAuth and API key)
|
||||
|
||||
@@ -26,7 +26,7 @@ class NexusPremiumService:
|
||||
is_oauth: True when auth_token is an OAuth Bearer token.
|
||||
|
||||
Returns:
|
||||
(is_premium, username) — both None/False on failure.
|
||||
(is_premium, username) - both None/False on failure.
|
||||
"""
|
||||
cached = self._read_cache(auth_token, is_oauth=is_oauth)
|
||||
if cached is not None:
|
||||
|
||||
@@ -132,13 +132,24 @@ class ProtontricksDetectionService:
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
# Use clean environment
|
||||
env = handler._get_clean_subprocess_env()
|
||||
|
||||
# Register flathub at user level if not already present.
|
||||
# Fresh Steam Decks only have flathub at system scope; --user install can't see it.
|
||||
try:
|
||||
subprocess.run(
|
||||
["flatpak", "remote-add", "--if-not-exists", "--user",
|
||||
"flathub", "https://dl.flathub.org/repo/flathub.flatpakrepo"],
|
||||
check=True, text=True, env=env, capture_output=True, timeout=30,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning(f"Could not register flathub remote: {e.stderr.strip()}")
|
||||
|
||||
# Install command - use --user flag for user-level installation (works on Steam Deck)
|
||||
# Avoids system-wide installation permissions
|
||||
install_cmd = ["flatpak", "install", "--user", "-y", "--noninteractive", "flathub", "com.github.Matoking.protontricks"]
|
||||
|
||||
# Use clean environment
|
||||
env = handler._get_clean_subprocess_env()
|
||||
|
||||
|
||||
# Log the command for debugging
|
||||
logger.debug(f"Running flatpak install command: {' '.join(install_cmd)}")
|
||||
|
||||
|
||||
@@ -20,8 +20,6 @@ def _get_restart_strategy() -> str:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
|
||||
strategy = ConfigHandler().get("steam_restart_strategy", STRATEGY_JACKIFY)
|
||||
if strategy == "nak_simple":
|
||||
strategy = STRATEGY_SIMPLE
|
||||
if strategy not in (STRATEGY_JACKIFY, STRATEGY_SIMPLE):
|
||||
return STRATEGY_JACKIFY
|
||||
return strategy
|
||||
@@ -203,7 +201,7 @@ def is_flatpak_steam() -> bool:
|
||||
def ensure_flatpak_steam_filesystem_access(path: "Path") -> bool:
|
||||
"""Grant Flatpak Steam filesystem access to the parent of the given path.
|
||||
|
||||
Safe to call on non-Flatpak systems — returns True immediately.
|
||||
Safe to call on non-Flatpak systems - returns True immediately.
|
||||
Skips if the path is already covered by an existing override.
|
||||
Returns True if access was already present or successfully granted, False on error.
|
||||
"""
|
||||
@@ -212,7 +210,7 @@ def ensure_flatpak_steam_filesystem_access(path: "Path") -> bool:
|
||||
return True
|
||||
flatpak_cmd = _get_flatpak_command()
|
||||
if not flatpak_cmd:
|
||||
logger.warning("Flatpak Steam detected but flatpak command not found — cannot grant filesystem access")
|
||||
logger.warning("Flatpak Steam detected but flatpak command not found - cannot grant filesystem access")
|
||||
return False
|
||||
grant_path = str(_Path(path).parent)
|
||||
env = _get_clean_subprocess_env()
|
||||
|
||||
181
jackify/backend/services/steamgriddb_service.py
Normal file
181
jackify/backend/services/steamgriddb_service.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
SteamGridDB artwork fetching service.
|
||||
|
||||
Fetches top-voted artwork for a game from steamgriddb.com using the
|
||||
official API. Used as a fallback when a modlist has no SteamIcons/ directory.
|
||||
|
||||
PRIVATE: This file contains an obfuscated API key. Do NOT sync to public-src.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://www.steamgriddb.com/api/v2"
|
||||
|
||||
# Obfuscated Jackify service key - XOR with mask, base64-encoded.
|
||||
# Keep this file out of public-src.
|
||||
_OBF = b"LgRUXwtXTwUEAw02cnR7EHgEVFldXklTUlFQNiQmJBM="
|
||||
_MSK = b"Jackify2024SGDB!Jackify2024SGDB!"
|
||||
|
||||
|
||||
def _get_api_key() -> str:
|
||||
raw = base64.b64decode(_OBF)
|
||||
return bytes(a ^ b for a, b in zip(raw, _MSK)).decode()
|
||||
|
||||
# Steam App IDs for each Jackify game type key
|
||||
GAME_STEAM_APP_IDS = {
|
||||
"skyrim": "489830",
|
||||
"skyrimvr": "611670",
|
||||
"fo4": "377160",
|
||||
"fallout4vr": "611660",
|
||||
"fnv": "22380",
|
||||
"fo3": "22300",
|
||||
"oblivion": "22330",
|
||||
"oblivion_remastered": "2623190",
|
||||
"enderal": "976620",
|
||||
"starfield": "1716740",
|
||||
"cp2077": "1091500",
|
||||
"bg3": "1086940",
|
||||
}
|
||||
|
||||
# Artwork slots: (endpoint_path, query_string, dest_filename)
|
||||
_ARTWORK_SLOTS = [
|
||||
("grids", "dimensions=600x900&types=static&nsfw=false", "grid-tall.png"),
|
||||
("grids", "dimensions=920x430&types=static&nsfw=false", "grid-wide.png"),
|
||||
("heroes", "dimensions=1920x620&types=static&nsfw=false", "grid-hero.png"),
|
||||
("logos", "types=static&nsfw=false", "grid-logo.png"),
|
||||
]
|
||||
|
||||
|
||||
def _api_get(endpoint: str, api_key: str) -> Optional[dict]:
|
||||
url = f"{_BASE_URL}/{endpoint}"
|
||||
req = urllib.request.Request(url, headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": "Jackify/0.6",
|
||||
})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
logger.warning(f"SteamGridDB API error {e.code} for {url}")
|
||||
except Exception as e:
|
||||
logger.warning(f"SteamGridDB request failed for {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _download(url: str, dest: Path) -> bool:
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Jackify/0.6"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
dest.write_bytes(resp.read())
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to download {url}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def detect_game_type_from_modlist(modlist_dir: str) -> Optional[str]:
|
||||
"""Read gameName= from ModOrganizer.ini and return the Jackify game type key.
|
||||
|
||||
Covers all supported game types. Returns None if the ini cannot be read or
|
||||
the game is not in GAME_STEAM_APP_IDS.
|
||||
"""
|
||||
if not modlist_dir:
|
||||
return None
|
||||
try:
|
||||
from pathlib import Path as _Path
|
||||
mo2_ini = _Path(modlist_dir) / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
mo2_ini = _Path(modlist_dir) / "files" / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
return None
|
||||
content = mo2_ini.read_text(errors='ignore').lower()
|
||||
game_name_value = ""
|
||||
for _line in content.splitlines():
|
||||
stripped = _line.strip()
|
||||
if "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
if key.strip().lower() == "gamename":
|
||||
game_name_value = value.strip()
|
||||
break
|
||||
gn = game_name_value.strip()
|
||||
if gn:
|
||||
if 'skyrim vr' in gn or 'skyrimvr' in gn:
|
||||
return "skyrimvr"
|
||||
if 'fallout 4 vr' in gn or 'fallout4vr' in gn:
|
||||
return "fallout4vr"
|
||||
if 'skyrim special edition' in gn:
|
||||
return "skyrim"
|
||||
if 'fallout new vegas' in gn or 'falloutnv' in gn or 'new vegas' in gn or gn == 'ttw':
|
||||
return "fnv"
|
||||
if 'fallout3' in gn or ('fallout 3' in gn and 'fallout 4' not in gn):
|
||||
return "fo3"
|
||||
if 'fallout 4' in gn:
|
||||
return "fo4"
|
||||
if 'starfield' in gn:
|
||||
return "starfield"
|
||||
if 'oblivion remastered' in gn:
|
||||
return "oblivion_remastered"
|
||||
if 'oblivion' in gn:
|
||||
return "oblivion"
|
||||
if 'enderal' in gn:
|
||||
return "enderal"
|
||||
if 'cyberpunk' in gn or 'cp2077' in gn:
|
||||
return "cp2077"
|
||||
if "baldur" in gn or 'bg3' in gn:
|
||||
return "bg3"
|
||||
else:
|
||||
# gameName= absent - fall back to content scan for common markers
|
||||
if 'skyrim special edition' in content or 'skse64_loader' in content:
|
||||
return "skyrim"
|
||||
if 'nvse_loader' in content or 'falloutnv' in content:
|
||||
return "fnv"
|
||||
if 'fose_loader' in content:
|
||||
return "fo3"
|
||||
if 'f4se_loader' in content:
|
||||
return "fo4"
|
||||
if 'baldur' in content or 'bg3' in content:
|
||||
return "bg3"
|
||||
if 'cyberpunk' in content or 'cp2077' in content:
|
||||
return "cp2077"
|
||||
if 'starfield' in content:
|
||||
return "starfield"
|
||||
except Exception as e:
|
||||
logger.debug(f"detect_game_type_from_modlist failed for {modlist_dir}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_artwork(game_type: str, dest_dir: Path) -> int:
|
||||
"""
|
||||
Fetch top-voted artwork for game_type from SteamGridDB into dest_dir.
|
||||
|
||||
Returns the number of images successfully downloaded.
|
||||
dest_dir must already exist.
|
||||
"""
|
||||
steam_appid = GAME_STEAM_APP_IDS.get(game_type)
|
||||
if not steam_appid:
|
||||
logger.debug(f"No Steam App ID mapping for game type: {game_type}")
|
||||
return 0
|
||||
|
||||
api_key = _get_api_key()
|
||||
downloaded = 0
|
||||
for endpoint, query, filename in _ARTWORK_SLOTS:
|
||||
data = _api_get(f"{endpoint}/steam/{steam_appid}?{query}", api_key)
|
||||
if not data or not data.get("success") or not data.get("data"):
|
||||
logger.debug(f"No {endpoint} results for {game_type} ({steam_appid})")
|
||||
continue
|
||||
image_url = data["data"][0]["url"]
|
||||
dest_path = dest_dir / filename
|
||||
if _download(image_url, dest_path):
|
||||
logger.info(f"Downloaded {filename} for {game_type} from SteamGridDB")
|
||||
downloaded += 1
|
||||
|
||||
return downloaded
|
||||
600
jackify/backend/services/tool_config_service.py
Normal file
600
jackify/backend/services/tool_config_service.py
Normal file
@@ -0,0 +1,600 @@
|
||||
"""
|
||||
Tool compatibility configuration service.
|
||||
|
||||
Applies Wine registry settings required for modding tools to work correctly
|
||||
on Linux. Applied automatically during prefix setup and available as a
|
||||
standalone operation for existing prefixes.
|
||||
|
||||
Based on research into NaK's registry configuration (external reference only).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# xEdit family executables that require WinXP compatibility mode.
|
||||
# Wine's default Windows version causes xEdit to fail on certain operations.
|
||||
_XEDIT_EXECUTABLES = [
|
||||
"SSEEdit.exe", "SSEEdit64.exe",
|
||||
"FO4Edit.exe", "FO4Edit64.exe",
|
||||
"TES4Edit.exe", "TES4Edit64.exe",
|
||||
"xEdit64.exe",
|
||||
"SF1Edit64.exe",
|
||||
"FNVEdit.exe", "FNVEdit64.exe",
|
||||
"xFOEdit.exe", "xFOEdit64.exe",
|
||||
"xSFEEdit.exe", "xSFEEdit64.exe",
|
||||
"xTESEdit.exe", "xTESEdit64.exe",
|
||||
"FO3Edit.exe", "FO3Edit64.exe",
|
||||
]
|
||||
|
||||
# DLL overrides applied to the prefix globally.
|
||||
# All set to native,builtin so game/tool-provided DLLs take priority.
|
||||
_DLL_OVERRIDES = [
|
||||
"dwrite",
|
||||
"winmm",
|
||||
"version",
|
||||
"dxgi",
|
||||
"dbghelp",
|
||||
"d3d12",
|
||||
"wininet",
|
||||
"winhttp",
|
||||
"dinput",
|
||||
"dinput8",
|
||||
]
|
||||
|
||||
|
||||
def _build_reg_content() -> str:
|
||||
lines = ["Windows Registry Editor Version 5.00", ""]
|
||||
|
||||
# xEdit WinXP compatibility
|
||||
for exe in _XEDIT_EXECUTABLES:
|
||||
lines.append(f"[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\{exe}]")
|
||||
lines.append('"Version"="winxp"')
|
||||
lines.append("")
|
||||
|
||||
# Pandora Behaviour Engine - decorated window causes UI glitches on Linux
|
||||
lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\Pandora Behaviour Engine+.exe\\X11 Driver]")
|
||||
lines.append('"Decorated"="N"')
|
||||
lines.append("")
|
||||
|
||||
# Skyrim SE / SKSE game process needs native mscoree to load dotnet4 correctly.
|
||||
# Scoped to SkyrimSE.exe only so it does not interfere with .NET 9/10 tools
|
||||
# (Synthesis, SDK host) that run in the same prefix.
|
||||
lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides]")
|
||||
lines.append('"*mscoree"="native"')
|
||||
lines.append("")
|
||||
|
||||
# Prevent Wine windows from stealing keyboard focus via WM_TAKE_FOCUS.
|
||||
# Without this, each Wine subprocess launched during winetricks installs
|
||||
# briefly grabs X11 focus (via XWayland), interrupting whatever the user
|
||||
# is typing in other applications.
|
||||
lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\X11 Driver]")
|
||||
lines.append('"UseTakeFocus"="N"')
|
||||
lines.append("")
|
||||
|
||||
# Global DLL overrides
|
||||
lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides]")
|
||||
for dll in _DLL_OVERRIDES:
|
||||
lines.append(f'"{dll}"="native,builtin"')
|
||||
lines.append("")
|
||||
|
||||
return "\r\n".join(lines)
|
||||
|
||||
|
||||
# .NET 9 SDK - direct installer, not available via winetricks.
|
||||
# Synthesis runs on .NET 9; the SDK (not just runtime) is required for patcher compilation.
|
||||
# Versions match Fluorine's confirmed-working prefix configuration.
|
||||
_DOTNET9_SDK_URL = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.310/dotnet-sdk-9.0.310-win-x64.exe"
|
||||
_DOTNET9_SDK_FILENAME = "dotnet-sdk-9.0.310-win-x64.exe"
|
||||
|
||||
# .NET Desktop Runtime 10 - provides NETCore.App + WindowsDesktop.App 10.0.2.
|
||||
# Covers Synthesis patchers targeting .NET 10 runtime.
|
||||
_DOTNET10_DESKTOP_URL = "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.2/windowsdesktop-runtime-10.0.2-win-x64.exe"
|
||||
_DOTNET10_DESKTOP_FILENAME = "windowsdesktop-runtime-10.0.2-win-x64.exe"
|
||||
|
||||
# DigiCert Universal Root CA - required for NuGet package signature validation.
|
||||
# Without this, dotnet fails to verify NuGet package signatures when Synthesis
|
||||
# compiles patchers. Imported into the Wine prefix Windows cert store so no
|
||||
# system-level changes are needed.
|
||||
_DIGICERT_CERT_URL = "https://cacerts.digicert.com/DigiCertTrustedRootG4.crt.pem"
|
||||
_DIGICERT_CERT_FILENAME = "DigiCertTrustedRootG4.crt.pem"
|
||||
|
||||
# fxc2 build of d3dcompiler_47 - required for Community Shaders shader compilation.
|
||||
# The winetricks-provided d3dcompiler_47 lacks support for certain shader models
|
||||
# used by Community Shaders, causing "failed shaders" during compilation.
|
||||
_FXC2_D3DCOMPILER_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompiler_47.dll"
|
||||
_FXC2_D3DCOMPILER_FILENAME = "fxc2_d3dcompiler_47.dll"
|
||||
|
||||
|
||||
def _install_dotnet9_sdk(
|
||||
prefix_path: Path,
|
||||
wine_bin: str,
|
||||
log: Callable[[str], None],
|
||||
) -> bool:
|
||||
"""
|
||||
Download and install the .NET 9 SDK into the Wine prefix.
|
||||
Cached to avoid re-downloading on subsequent runs.
|
||||
"""
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
cache_dir = get_jackify_data_dir() / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
installer = cache_dir / _DOTNET9_SDK_FILENAME
|
||||
|
||||
if not installer.exists():
|
||||
log(f"Downloading .NET 9 SDK ({_DOTNET9_SDK_FILENAME})...")
|
||||
urllib.request.urlretrieve(_DOTNET9_SDK_URL, installer)
|
||||
log(".NET 9 SDK downloaded")
|
||||
else:
|
||||
log(".NET 9 SDK installer already cached, skipping download")
|
||||
|
||||
log("Installing .NET 9 SDK (this may take a few minutes)...")
|
||||
env = os.environ.copy()
|
||||
env["WINEPREFIX"] = str(prefix_path)
|
||||
env["WINEDEBUG"] = "-all"
|
||||
env["WINEDLLOVERRIDES"] = "mshtml=d;winemenubuilder.exe=d"
|
||||
env["DISPLAY"] = env.get("DISPLAY", ":0")
|
||||
|
||||
result = subprocess.run(
|
||||
[wine_bin, str(installer), "/install", "/quiet", "/norestart"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
|
||||
if result.returncode not in (0, 3010): # 3010 = success, reboot required
|
||||
log(f".NET 9 SDK installer exited with code {result.returncode}")
|
||||
return False
|
||||
|
||||
log(".NET 9 SDK installed successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log(f"Failed to install .NET 9 SDK: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def _install_dotnet10_desktop_runtime(
|
||||
prefix_path: Path,
|
||||
wine_bin: str,
|
||||
log: Callable[[str], None],
|
||||
) -> bool:
|
||||
"""
|
||||
Download and install the .NET Desktop Runtime 10 into the Wine prefix.
|
||||
Provides NETCore.App and WindowsDesktop.App 10.x for patchers targeting .NET 10.
|
||||
"""
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
cache_dir = get_jackify_data_dir() / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
installer = cache_dir / _DOTNET10_DESKTOP_FILENAME
|
||||
|
||||
if not installer.exists():
|
||||
log(f"Downloading .NET Desktop Runtime 10 ({_DOTNET10_DESKTOP_FILENAME})...")
|
||||
urllib.request.urlretrieve(_DOTNET10_DESKTOP_URL, installer)
|
||||
log(".NET Desktop Runtime 10 downloaded")
|
||||
else:
|
||||
log(".NET Desktop Runtime 10 already cached, skipping download")
|
||||
|
||||
log("Installing .NET Desktop Runtime 10...")
|
||||
env = os.environ.copy()
|
||||
env["WINEPREFIX"] = str(prefix_path)
|
||||
env["WINEDEBUG"] = "-all"
|
||||
env["WINEDLLOVERRIDES"] = "mshtml=d;winemenubuilder.exe=d"
|
||||
env["DISPLAY"] = env.get("DISPLAY", ":0")
|
||||
|
||||
result = subprocess.run(
|
||||
[wine_bin, str(installer), "/install", "/quiet", "/norestart"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
if result.returncode not in (0, 3010):
|
||||
log(f".NET Desktop Runtime 10 installer exited with code {result.returncode}")
|
||||
return False
|
||||
|
||||
log(".NET Desktop Runtime 10 installed successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log(f"Failed to install .NET Desktop Runtime 10: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _install_nuget_cert(
|
||||
prefix_path: Path,
|
||||
wine_bin: str,
|
||||
log: Callable[[str], None],
|
||||
) -> bool:
|
||||
"""
|
||||
Import the DigiCert Trusted Root G4 CA into the Wine prefix Windows cert
|
||||
store. Required for NuGet package signature validation when Synthesis
|
||||
compiles patchers. Uses wine certutil so no system-level changes are needed.
|
||||
"""
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
cache_dir = get_jackify_data_dir() / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cert_file = cache_dir / _DIGICERT_CERT_FILENAME
|
||||
|
||||
if not cert_file.exists():
|
||||
log(f"Downloading DigiCert Trusted Root G4 certificate...")
|
||||
urllib.request.urlretrieve(_DIGICERT_CERT_URL, cert_file)
|
||||
log("Certificate downloaded")
|
||||
else:
|
||||
log("DigiCert certificate already cached, skipping download")
|
||||
|
||||
log("Importing certificate into Wine prefix cert store...")
|
||||
env = os.environ.copy()
|
||||
env["WINEPREFIX"] = str(prefix_path)
|
||||
env["WINEDEBUG"] = "-all"
|
||||
env["WINEDLLOVERRIDES"] = "winemenubuilder.exe=d"
|
||||
env["DISPLAY"] = env.get("DISPLAY", ":0")
|
||||
|
||||
result = subprocess.run(
|
||||
[wine_bin, "certutil", "-addstore", "Root", str(cert_file)],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
log(f"certutil exited with code {result.returncode} (may already be installed)")
|
||||
else:
|
||||
log("DigiCert certificate imported into Wine cert store")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log(f"Failed to install NuGet certificate: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def _install_fxc2_d3dcompiler(
|
||||
prefix_path: Path,
|
||||
log: Callable[[str], None],
|
||||
) -> bool:
|
||||
"""
|
||||
Replace the winetricks-installed d3dcompiler_47.dll with the Mozilla fxc2
|
||||
build, which supports shader models required by Community Shaders.
|
||||
Applies to both system32 (64-bit) and syswow64 (32-bit) locations.
|
||||
"""
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
cache_dir = get_jackify_data_dir() / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cached_dll = cache_dir / _FXC2_D3DCOMPILER_FILENAME
|
||||
|
||||
if not cached_dll.exists():
|
||||
log("Downloading fxc2 d3dcompiler_47.dll...")
|
||||
urllib.request.urlretrieve(_FXC2_D3DCOMPILER_URL, cached_dll)
|
||||
log("fxc2 d3dcompiler_47.dll downloaded")
|
||||
else:
|
||||
log("fxc2 d3dcompiler_47.dll already cached, skipping download")
|
||||
|
||||
import shutil
|
||||
targets = [
|
||||
prefix_path / "drive_c" / "windows" / "system32" / "d3dcompiler_47.dll",
|
||||
prefix_path / "drive_c" / "windows" / "syswow64" / "d3dcompiler_47.dll",
|
||||
]
|
||||
for target in targets:
|
||||
if target.parent.exists():
|
||||
shutil.copy2(cached_dll, target)
|
||||
log(f"Installed fxc2 d3dcompiler_47.dll -> {target.parent.name}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log(f"Failed to install fxc2 d3dcompiler_47.dll (non-fatal): {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _set_windows_version_win11(
|
||||
prefix_path: Path,
|
||||
wine_bin: str,
|
||||
log: Callable[[str], None],
|
||||
) -> None:
|
||||
"""
|
||||
Set the Wine prefix Windows version to Windows 11.
|
||||
Matches Fluorine's prefix configuration; required for .NET 9/10 to run
|
||||
correctly. winetricks components may leave the prefix at a lower version.
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path as _Path
|
||||
module_dir = _Path(__file__).parent.parent.parent
|
||||
winetricks_bin = str(module_dir / "tools" / "winetricks")
|
||||
if not os.path.exists(winetricks_bin):
|
||||
appdir = os.environ.get("APPDIR", "")
|
||||
if appdir:
|
||||
winetricks_bin = os.path.join(appdir, "opt", "jackify", "tools", "winetricks")
|
||||
if not os.path.exists(winetricks_bin):
|
||||
log("Bundled winetricks not found - skipping Windows version update")
|
||||
return
|
||||
|
||||
log("Setting Windows version to Windows 11...")
|
||||
env = os.environ.copy()
|
||||
env["WINEPREFIX"] = str(prefix_path)
|
||||
env["WINE"] = wine_bin
|
||||
env["WINEDEBUG"] = "-all"
|
||||
env["DISPLAY"] = env.get("DISPLAY", ":0")
|
||||
|
||||
result = subprocess.run(
|
||||
[winetricks_bin, "-q", "win11"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
log(f"winetricks win11 exited with code {result.returncode} (non-fatal)")
|
||||
else:
|
||||
log("Windows version set to Windows 11")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
log("winetricks win10 timed out (non-fatal)")
|
||||
except Exception as e:
|
||||
log(f"Failed to set Windows version: {e} (non-fatal)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply_tool_config(
|
||||
compatdata_path: str,
|
||||
wine_bin: str,
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
install_dotnet9_sdk: bool = False,
|
||||
install_fxc2_d3dcompiler: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Apply tool compatibility settings to the Wine prefix.
|
||||
|
||||
install_dotnet9_sdk=True downloads and installs the .NET 9/10 SDK, which is
|
||||
required for Synthesis. Intentionally opt-in - the download is ~220MB and
|
||||
only appropriate when the user explicitly runs Configure Tool Compatibility
|
||||
from Additional Tasks.
|
||||
|
||||
install_fxc2_d3dcompiler=True replaces d3dcompiler_47.dll with the Mozilla
|
||||
fxc2 build. Only appropriate for Skyrim SE/AE modlists using Community Shaders.
|
||||
|
||||
Returns True if registry settings applied successfully (dotnet SDK install
|
||||
failures are non-fatal since the registry settings still have value).
|
||||
"""
|
||||
def _log(msg: str):
|
||||
logger.info(msg)
|
||||
if log:
|
||||
log(msg)
|
||||
|
||||
prefix_path = Path(compatdata_path) / "pfx"
|
||||
if not prefix_path.exists():
|
||||
_log(f"Wine prefix not found at {prefix_path}")
|
||||
return False
|
||||
|
||||
if install_fxc2_d3dcompiler:
|
||||
_install_fxc2_d3dcompiler(prefix_path, _log)
|
||||
|
||||
if install_dotnet9_sdk:
|
||||
_install_dotnet9_sdk(prefix_path, wine_bin, _log)
|
||||
_install_dotnet10_desktop_runtime(prefix_path, wine_bin, _log)
|
||||
_install_nuget_cert(prefix_path, wine_bin, _log)
|
||||
_set_windows_version_win11(prefix_path, wine_bin, _log)
|
||||
|
||||
# Remove legacy global *mscoree=native from DllOverrides if present.
|
||||
# Old installs wrote this globally, which breaks .NET 9/10 bootstrap (Synthesis).
|
||||
# The targeted AppDefaults\SkyrimSE.exe entry written below replaces it.
|
||||
try:
|
||||
env_clean = os.environ.copy()
|
||||
env_clean["WINEPREFIX"] = str(prefix_path)
|
||||
env_clean["WINEDEBUG"] = "-all"
|
||||
env_clean["DISPLAY"] = env_clean.get("DISPLAY", ":0")
|
||||
subprocess.run(
|
||||
[wine_bin, "reg", "delete",
|
||||
"HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides",
|
||||
"/v", "*mscoree", "/f"],
|
||||
env=env_clean, capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
_log("Removed legacy global *mscoree override (if present)")
|
||||
except Exception as e:
|
||||
_log(f"Note: could not remove legacy mscoree entry (non-fatal): {e}")
|
||||
|
||||
reg_content = _build_reg_content()
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".reg", delete=False, encoding="utf-8"
|
||||
) as tf:
|
||||
tf.write(reg_content)
|
||||
reg_file = tf.name
|
||||
|
||||
_log("Applying tool compatibility registry settings...")
|
||||
env = os.environ.copy()
|
||||
env["WINEPREFIX"] = str(prefix_path)
|
||||
env["WINEDEBUG"] = "-all"
|
||||
env["DISPLAY"] = env.get("DISPLAY", ":0")
|
||||
|
||||
result = subprocess.run(
|
||||
[wine_bin, "regedit", reg_file],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
_log(f"wine regedit exited with code {result.returncode}: {result.stderr[:200]}")
|
||||
return False
|
||||
|
||||
_log(f"Tool compatibility settings applied ({len(_XEDIT_EXECUTABLES)} xEdit variants, Pandora, {len(_DLL_OVERRIDES)} DLL overrides)")
|
||||
return True
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
_log("wine regedit timed out after 30 seconds")
|
||||
return False
|
||||
except Exception as e:
|
||||
_log(f"Failed to apply tool config: {e}")
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
os.unlink(reg_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def setup_nemesis_compatibility(
|
||||
modlist_dir: str,
|
||||
stock_game_path: Optional[str],
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Prepare Nemesis Unlimited Behavior Engine to run correctly on Linux.
|
||||
|
||||
Two issues affect Nemesis under Wine/MO2 on Linux:
|
||||
1. Nemesis resolves a relative `mods` path against the filesystem root,
|
||||
causing a "cannot access /mods" error. Symlinking Nemesis_Engine from
|
||||
the mod directory into the real Data directory fixes this.
|
||||
2. A non-blank "Start In" (workingDirectory) in ModOrganizer.ini causes
|
||||
Nemesis to hang. Blank it out for the Nemesis executable entry.
|
||||
|
||||
Non-fatal - logs failures but does not raise.
|
||||
"""
|
||||
def _log(msg: str):
|
||||
logger.info(msg)
|
||||
if log:
|
||||
log(msg)
|
||||
|
||||
modlist_path = Path(modlist_dir)
|
||||
mods_dir = modlist_path / "mods"
|
||||
|
||||
if not mods_dir.is_dir():
|
||||
_log("Nemesis setup: mods directory not found, skipping")
|
||||
return
|
||||
|
||||
# Find the Nemesis_Engine directory inside the mods tree
|
||||
nemesis_engine_src: Optional[Path] = None
|
||||
try:
|
||||
for mod_dir in mods_dir.iterdir():
|
||||
candidate = mod_dir / "Nemesis_Engine"
|
||||
if candidate.is_dir():
|
||||
nemesis_engine_src = candidate
|
||||
break
|
||||
except Exception as e:
|
||||
_log(f"Nemesis setup: error scanning mods directory: {e}")
|
||||
return
|
||||
|
||||
if nemesis_engine_src is None:
|
||||
_log("Nemesis setup: Nemesis_Engine not found in mods - modlist may not include Nemesis")
|
||||
return
|
||||
|
||||
# Create symlink in Data/ so Nemesis can find its engine at a predictable path
|
||||
if stock_game_path:
|
||||
data_dir = Path(stock_game_path) / "Data"
|
||||
try:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
symlink_path = data_dir / "Nemesis_Engine"
|
||||
if symlink_path.is_symlink():
|
||||
existing_target = symlink_path.resolve()
|
||||
if existing_target == nemesis_engine_src.resolve():
|
||||
_log("Nemesis setup: symlink already correct, skipping")
|
||||
else:
|
||||
symlink_path.unlink()
|
||||
symlink_path.symlink_to(nemesis_engine_src)
|
||||
_log(f"Nemesis setup: updated symlink at {symlink_path}")
|
||||
elif symlink_path.exists():
|
||||
_log(f"Nemesis setup: {symlink_path} exists and is not a symlink - leaving it alone")
|
||||
else:
|
||||
symlink_path.symlink_to(nemesis_engine_src)
|
||||
_log(f"Nemesis setup: created symlink {symlink_path} -> {nemesis_engine_src}")
|
||||
except Exception as e:
|
||||
_log(f"Nemesis setup: failed to create symlink: {e}")
|
||||
else:
|
||||
_log("Nemesis setup: no stock game path available - skipping symlink")
|
||||
|
||||
# Blank workingDirectory for the Nemesis executable in ModOrganizer.ini
|
||||
mo2_ini = modlist_path / "ModOrganizer.ini"
|
||||
if not mo2_ini.is_file():
|
||||
_log("Nemesis setup: ModOrganizer.ini not found, skipping workingDirectory fix")
|
||||
return
|
||||
|
||||
try:
|
||||
content = mo2_ini.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
_log(f"Nemesis setup: could not read ModOrganizer.ini: {e}")
|
||||
return
|
||||
|
||||
import re
|
||||
|
||||
# Find all executable indices whose binary points to Nemesis
|
||||
nemesis_indices = re.findall(
|
||||
r'^(\d+)\\binary=.*Nemesis Unlimited Behavior Engine\.exe',
|
||||
content,
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
)
|
||||
|
||||
if not nemesis_indices:
|
||||
_log("Nemesis setup: no Nemesis executable entry found in ModOrganizer.ini")
|
||||
return
|
||||
|
||||
modified = content
|
||||
changed = 0
|
||||
for idx in nemesis_indices:
|
||||
# Replace non-blank workingDirectory for this index
|
||||
pattern = rf'^({re.escape(idx)}\\workingDirectory=).+$'
|
||||
replacement = rf'\g<1>'
|
||||
new_content, n = re.subn(pattern, replacement, modified, flags=re.MULTILINE)
|
||||
if n:
|
||||
modified = new_content
|
||||
changed += n
|
||||
|
||||
if changed:
|
||||
try:
|
||||
mo2_ini.write_text(modified, encoding="utf-8")
|
||||
_log(f"Nemesis setup: blanked workingDirectory for {len(nemesis_indices)} Nemesis executable entry(s) in ModOrganizer.ini")
|
||||
except Exception as e:
|
||||
_log(f"Nemesis setup: failed to write ModOrganizer.ini: {e}")
|
||||
else:
|
||||
_log("Nemesis setup: workingDirectory already blank for all Nemesis entries")
|
||||
|
||||
|
||||
def apply_tool_config_for_appid(
|
||||
appid: str,
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
install_dotnet9_sdk: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
Resolve compatdata path and wine binary from an AppID, then apply tool config.
|
||||
Convenience wrapper for the standalone Additional Tasks flow.
|
||||
"""
|
||||
def _log(msg: str):
|
||||
logger.info(msg)
|
||||
if log:
|
||||
log(msg)
|
||||
|
||||
try:
|
||||
from jackify.backend.handlers.wine_utils_proton import WineUtilsProtonMixin
|
||||
compatdata_path, _, wine_bin = WineUtilsProtonMixin.get_proton_paths(appid)
|
||||
except Exception as e:
|
||||
_log(f"Could not resolve Proton paths for AppID {appid}: {e}")
|
||||
return False
|
||||
|
||||
if not compatdata_path or not wine_bin:
|
||||
_log(f"Could not resolve Wine prefix for AppID {appid}. Is this modlist configured in Steam?")
|
||||
return False
|
||||
|
||||
return apply_tool_config(compatdata_path, wine_bin, log, install_dotnet9_sdk=install_dotnet9_sdk, install_fxc2_d3dcompiler=True)
|
||||
503
jackify/backend/services/tool_registry.py
Normal file
503
jackify/backend/services/tool_registry.py
Normal file
@@ -0,0 +1,503 @@
|
||||
"""
|
||||
Third-party tool registry.
|
||||
|
||||
Manages install, update, downgrade, and uninstall of independently-versioned
|
||||
tools that Jackify either invokes directly (Tier 1) or makes available for users
|
||||
to run from MO2 (Tier 2).
|
||||
|
||||
Each tool stores a manifest at:
|
||||
$jackify_data_dir/tools/<tool_id>/manifest.json
|
||||
|
||||
TTW_Linux_Installer is a special case: it has a pre-existing handler with its
|
||||
own config keys. The registry reads those keys for status display and delegates
|
||||
install/update to the existing handler rather than managing storage itself.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tarfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TOOLS_BASE_DIR = get_jackify_data_dir() / "tools"
|
||||
GITHUB_API = "https://api.github.com/repos/{repo}/releases/{ref}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolDefinition:
|
||||
tool_id: str
|
||||
display_name: str
|
||||
description: str
|
||||
github_repo: str # e.g. "SulfurNitride/CLF3"
|
||||
asset_patterns: List[str] # ordered list of regex patterns to match release asset filename
|
||||
tier: int # 1 = Jackify invokes it, 2 = user runs it themselves
|
||||
executable_names: List[str] = field(default_factory=list)
|
||||
pinned_version: Optional[str] = None # None = always use latest
|
||||
can_uninstall: bool = True # False for tools Jackify hard-depends on
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolStatus:
|
||||
definition: ToolDefinition
|
||||
installed: bool
|
||||
installed_version: Optional[str]
|
||||
previous_version: Optional[str]
|
||||
binary_path: Optional[Path]
|
||||
latest_version: Optional[str] = None
|
||||
update_available: bool = False
|
||||
|
||||
@property
|
||||
def can_downgrade(self) -> bool:
|
||||
prev_dir = TOOLS_BASE_DIR / self.definition.tool_id / "_previous"
|
||||
return self.previous_version is not None and prev_dir.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool catalogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOL_DEFINITIONS: List[ToolDefinition] = [
|
||||
ToolDefinition(
|
||||
tool_id="ttw_installer",
|
||||
display_name="TTW Linux Installer",
|
||||
description="Automates Tale of Two Wastelands installation on Linux. Required for the TTW workflow.",
|
||||
github_repo="SulfurNitride/TTW_Linux_Installer",
|
||||
asset_patterns=[r"universal-mpi-installer.*\.(zip|tar\.gz)"],
|
||||
executable_names=["mpi_installer", "ttw_linux_gui"],
|
||||
tier=1,
|
||||
can_uninstall=False,
|
||||
),
|
||||
ToolDefinition(
|
||||
tool_id="clf3",
|
||||
display_name="CLF3",
|
||||
description="Rust-based Wabbajack file handler. Planned as an experimental engine alternative.",
|
||||
github_repo="SulfurNitride/CLF3",
|
||||
asset_patterns=[r"clf3.*linux.*x86_64", r"clf3.*\.tar\.gz", r"clf3.*\.zip"],
|
||||
executable_names=["clf3"],
|
||||
tier=1,
|
||||
can_uninstall=True,
|
||||
),
|
||||
ToolDefinition(
|
||||
tool_id="fluorine",
|
||||
display_name="Fluorine Manager",
|
||||
description="Linux-native MO2 port with FUSE-based VFS and built-in Rootbuilder support.",
|
||||
github_repo="SulfurNitride/Fluorine-Manager",
|
||||
asset_patterns=[r"fluorine.*\.appimage", r"fluorine.*\.tar\.gz", r"fluorine.*\.zip"],
|
||||
executable_names=["Fluorine", "fluorine"],
|
||||
tier=2,
|
||||
),
|
||||
ToolDefinition(
|
||||
tool_id="bodyslide",
|
||||
display_name="BodySlide (Linux Port)",
|
||||
description="BodySlide and Outfit Studio ported to Linux. For body/outfit mesh conversion.",
|
||||
github_repo="SulfurNitride/BodySlide-and-Outfit-Studio-Linux-Port",
|
||||
asset_patterns=[r"bodyslide.*linux.*\.(appimage|tar\.gz|zip)", r".*bodyslide.*\.(tar\.gz|zip)"],
|
||||
executable_names=["BodySlide", "BodySlide_x64"],
|
||||
tier=2,
|
||||
),
|
||||
ToolDefinition(
|
||||
tool_id="radium",
|
||||
display_name="Radium Textures",
|
||||
description="Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation.",
|
||||
github_repo="SulfurNitride/Radium-Textures",
|
||||
asset_patterns=[r"radium.*linux.*x86_64", r"radium.*\.tar\.gz", r"radium.*\.zip"],
|
||||
executable_names=["radium", "radium-textures"],
|
||||
tier=2,
|
||||
),
|
||||
]
|
||||
|
||||
_TOOL_MAP: Dict[str, ToolDefinition] = {t.tool_id: t for t in TOOL_DEFINITIONS}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _manifest_path(tool_id: str) -> Path:
|
||||
return TOOLS_BASE_DIR / tool_id / "manifest.json"
|
||||
|
||||
|
||||
def _read_manifest(tool_id: str) -> dict:
|
||||
mp = _manifest_path(tool_id)
|
||||
if mp.exists():
|
||||
try:
|
||||
return json.loads(mp.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _write_manifest(tool_id: str, data: dict) -> None:
|
||||
mp = _manifest_path(tool_id)
|
||||
mp.parent.mkdir(parents=True, exist_ok=True)
|
||||
mp.write_text(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TTW bridge - reads existing config keys written by TTWInstallerHandler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ttw_status_from_config() -> Tuple[bool, Optional[str], Optional[Path]]:
|
||||
"""Return (installed, version, binary_path) by reading TTWInstallerHandler config."""
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
cfg = ConfigHandler()
|
||||
version = cfg.get("ttw_installer_version")
|
||||
install_path_str = cfg.get("ttw_installer_install_path")
|
||||
if not install_path_str:
|
||||
return False, None, None
|
||||
install_dir = Path(install_path_str)
|
||||
for exe_name in ["mpi_installer", "ttw_linux_gui"]:
|
||||
exe = install_dir / exe_name
|
||||
if exe.is_file():
|
||||
return True, str(version) if version else None, exe
|
||||
return False, None, None
|
||||
except Exception as e:
|
||||
logger.debug("TTW config read failed: %s", e)
|
||||
return False, None, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHub release fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_latest_release_info(github_repo: str, pinned_version: Optional[str] = None) -> Optional[dict]:
|
||||
"""Fetch release metadata from GitHub API. Returns parsed JSON or None on failure."""
|
||||
if pinned_version:
|
||||
tags = [pinned_version, f"v{pinned_version}"] if not pinned_version.startswith("v") else [pinned_version]
|
||||
for tag in tags:
|
||||
url = GITHUB_API.format(repo=github_repo, ref=f"tags/{tag}")
|
||||
try:
|
||||
resp = requests.get(url, timeout=10, verify=True)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.debug("GitHub fetch error for %s@%s: %s", github_repo, tag, e)
|
||||
return None
|
||||
url = GITHUB_API.format(repo=github_repo, ref="latest")
|
||||
try:
|
||||
resp = requests.get(url, timeout=10, verify=True)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.debug("GitHub fetch error for %s: %s", github_repo, e)
|
||||
return None
|
||||
|
||||
|
||||
def _find_asset(release_data: dict, asset_patterns: List[str]) -> Optional[dict]:
|
||||
assets = release_data.get("assets", [])
|
||||
for pattern in asset_patterns:
|
||||
for asset in assets:
|
||||
if re.search(pattern, asset.get("name", ""), re.IGNORECASE):
|
||||
return asset
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core install logic (shared across all non-TTW tools)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _download_and_extract(tool_id: str, asset: dict, target_dir: Path) -> Tuple[bool, str]:
|
||||
"""Download a release asset and extract it into target_dir."""
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fs = FileSystemHandler()
|
||||
|
||||
asset_name = asset.get("name", "")
|
||||
download_url = asset.get("browser_download_url", "")
|
||||
if not download_url:
|
||||
return False, "Asset has no download URL"
|
||||
|
||||
temp_path = target_dir / asset_name
|
||||
logger.info("Downloading %s", asset_name)
|
||||
if not fs.download_file(download_url, temp_path, overwrite=True, quiet=True):
|
||||
return False, f"Download failed: {asset_name}"
|
||||
|
||||
try:
|
||||
name_lower = asset_name.lower()
|
||||
is_archive = False
|
||||
if name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
|
||||
is_archive = True
|
||||
with tarfile.open(temp_path, "r:gz") as tf:
|
||||
tf.extractall(path=target_dir)
|
||||
elif name_lower.endswith(".zip"):
|
||||
is_archive = True
|
||||
with zipfile.ZipFile(temp_path, "r") as zf:
|
||||
zf.extractall(path=target_dir)
|
||||
elif name_lower.endswith(".appimage"):
|
||||
temp_path.chmod(0o755)
|
||||
else:
|
||||
return False, f"Unsupported archive format: {asset_name}"
|
||||
finally:
|
||||
if is_archive:
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Path]:
|
||||
for exe_name in tool_def.executable_names:
|
||||
direct = search_dir / exe_name
|
||||
if direct.is_file():
|
||||
return direct
|
||||
for found in search_dir.rglob(exe_name):
|
||||
if found.is_file():
|
||||
return found
|
||||
# AppImage pattern
|
||||
for found in search_dir.rglob(f"{exe_name}*.AppImage"):
|
||||
if found.is_file():
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ToolRegistry:
|
||||
"""Read/write interface to the managed tool store."""
|
||||
|
||||
def get_status(self, tool_id: str) -> Optional[ToolStatus]:
|
||||
defn = _TOOL_MAP.get(tool_id)
|
||||
if defn is None:
|
||||
return None
|
||||
return self._build_status(defn)
|
||||
|
||||
def get_all_statuses(self) -> List[ToolStatus]:
|
||||
return [self._build_status(d) for d in TOOL_DEFINITIONS]
|
||||
|
||||
def check_latest_version(self, tool_id: str) -> Optional[str]:
|
||||
"""Fetch latest tag from GitHub. Returns tag string or None."""
|
||||
defn = _TOOL_MAP.get(tool_id)
|
||||
if defn is None:
|
||||
return None
|
||||
data = fetch_latest_release_info(defn.github_repo, defn.pinned_version)
|
||||
if data:
|
||||
return data.get("tag_name") or data.get("name")
|
||||
return None
|
||||
|
||||
def install(self, tool_id: str) -> Tuple[bool, str]:
|
||||
defn = _TOOL_MAP.get(tool_id)
|
||||
if defn is None:
|
||||
return False, f"Unknown tool: {tool_id}"
|
||||
|
||||
if tool_id == "ttw_installer":
|
||||
return self._install_ttw()
|
||||
|
||||
install_dir = TOOLS_BASE_DIR / tool_id
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = fetch_latest_release_info(defn.github_repo, defn.pinned_version)
|
||||
if not data:
|
||||
return False, f"Could not fetch release info for {defn.display_name}"
|
||||
|
||||
asset = _find_asset(data, defn.asset_patterns)
|
||||
if not asset:
|
||||
all_names = [a.get("name", "") for a in data.get("assets", [])]
|
||||
return False, f"No matching asset found. Available: {', '.join(all_names)}"
|
||||
|
||||
tag = data.get("tag_name") or data.get("name", "unknown")
|
||||
ok, err = _download_and_extract(tool_id, asset, install_dir)
|
||||
if not ok:
|
||||
return False, err
|
||||
|
||||
exe_path = _find_executable(defn, install_dir)
|
||||
if exe_path:
|
||||
try:
|
||||
os.chmod(exe_path, 0o755)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
manifest = _read_manifest(tool_id)
|
||||
_write_manifest(tool_id, {
|
||||
"installed_version": tag,
|
||||
"previous_version": manifest.get("installed_version"),
|
||||
"binary_path": str(exe_path) if exe_path else None,
|
||||
"install_dir": str(install_dir),
|
||||
})
|
||||
|
||||
logger.info("Installed %s %s", defn.display_name, tag)
|
||||
return True, f"{defn.display_name} {tag} installed"
|
||||
|
||||
def update(self, tool_id: str) -> Tuple[bool, str]:
|
||||
"""Update to latest release. Saves current as previous for downgrade."""
|
||||
defn = _TOOL_MAP.get(tool_id)
|
||||
if defn is None:
|
||||
return False, f"Unknown tool: {tool_id}"
|
||||
|
||||
if tool_id == "ttw_installer":
|
||||
return self._install_ttw()
|
||||
|
||||
manifest = _read_manifest(tool_id)
|
||||
current_dir = TOOLS_BASE_DIR / tool_id
|
||||
prev_dir = TOOLS_BASE_DIR / tool_id / "_previous"
|
||||
|
||||
# Back up current install before overwriting
|
||||
if current_dir.exists() and manifest.get("installed_version"):
|
||||
import shutil
|
||||
try:
|
||||
if prev_dir.exists():
|
||||
shutil.rmtree(prev_dir)
|
||||
# Copy current files (excluding _previous subdir) to _previous
|
||||
prev_dir.mkdir(parents=True, exist_ok=True)
|
||||
for item in current_dir.iterdir():
|
||||
if item.name == "_previous":
|
||||
continue
|
||||
dest = prev_dir / item.name
|
||||
if item.is_file():
|
||||
shutil.copy2(item, dest)
|
||||
elif item.is_dir():
|
||||
shutil.copytree(item, dest)
|
||||
except Exception as e:
|
||||
logger.warning("Could not back up previous version of %s: %s", tool_id, e)
|
||||
|
||||
ok, msg = self.install(tool_id)
|
||||
if ok and manifest.get("installed_version"):
|
||||
# Preserve previous_version in manifest (install() sets it from current manifest)
|
||||
updated_manifest = _read_manifest(tool_id)
|
||||
updated_manifest["previous_version"] = manifest.get("installed_version")
|
||||
_write_manifest(tool_id, updated_manifest)
|
||||
return ok, msg
|
||||
|
||||
def downgrade(self, tool_id: str) -> Tuple[bool, str]:
|
||||
"""Swap current install with the backed-up previous version."""
|
||||
defn = _TOOL_MAP.get(tool_id)
|
||||
if defn is None:
|
||||
return False, f"Unknown tool: {tool_id}"
|
||||
if tool_id == "ttw_installer":
|
||||
return False, "Downgrade not supported for TTW Linux Installer via this interface"
|
||||
|
||||
import shutil
|
||||
current_dir = TOOLS_BASE_DIR / tool_id
|
||||
prev_dir = TOOLS_BASE_DIR / tool_id / "_previous"
|
||||
|
||||
if not prev_dir.exists():
|
||||
return False, f"No previous version stored for {defn.display_name}"
|
||||
|
||||
manifest = _read_manifest(tool_id)
|
||||
current_version = manifest.get("installed_version")
|
||||
previous_version = manifest.get("previous_version")
|
||||
|
||||
# Swap: move current out, move previous in
|
||||
swap_dir = TOOLS_BASE_DIR / tool_id / "_swap"
|
||||
try:
|
||||
if swap_dir.exists():
|
||||
shutil.rmtree(swap_dir)
|
||||
swap_dir.mkdir(parents=True)
|
||||
for item in current_dir.iterdir():
|
||||
if item.name in ("_previous", "_swap"):
|
||||
continue
|
||||
shutil.move(str(item), str(swap_dir / item.name))
|
||||
for item in prev_dir.iterdir():
|
||||
shutil.move(str(item), str(current_dir / item.name))
|
||||
# Put what was current into _previous
|
||||
if prev_dir.exists():
|
||||
shutil.rmtree(prev_dir)
|
||||
prev_dir.mkdir()
|
||||
for item in swap_dir.iterdir():
|
||||
shutil.move(str(item), str(prev_dir / item.name))
|
||||
shutil.rmtree(swap_dir, ignore_errors=True)
|
||||
except Exception as e:
|
||||
return False, f"Downgrade failed: {e}"
|
||||
|
||||
exe_path = _find_executable(defn, current_dir)
|
||||
if exe_path:
|
||||
try:
|
||||
os.chmod(exe_path, 0o755)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_write_manifest(tool_id, {
|
||||
"installed_version": previous_version,
|
||||
"previous_version": current_version,
|
||||
"binary_path": str(exe_path) if exe_path else None,
|
||||
"install_dir": str(current_dir),
|
||||
})
|
||||
logger.info("Downgraded %s from %s to %s", defn.display_name, current_version, previous_version)
|
||||
return True, f"{defn.display_name} downgraded to {previous_version}"
|
||||
|
||||
def uninstall(self, tool_id: str) -> Tuple[bool, str]:
|
||||
defn = _TOOL_MAP.get(tool_id)
|
||||
if defn is None:
|
||||
return False, f"Unknown tool: {tool_id}"
|
||||
if not defn.can_uninstall:
|
||||
return False, f"{defn.display_name} cannot be uninstalled - Jackify depends on it"
|
||||
|
||||
import shutil
|
||||
tool_dir = TOOLS_BASE_DIR / tool_id
|
||||
if tool_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(tool_dir)
|
||||
except Exception as e:
|
||||
return False, f"Uninstall failed: {e}"
|
||||
|
||||
logger.info("Uninstalled %s", defn.display_name)
|
||||
return True, f"{defn.display_name} uninstalled"
|
||||
|
||||
def get_binary_path(self, tool_id: str) -> Optional[Path]:
|
||||
"""Return the installed binary path for a Tier 1 tool, or None."""
|
||||
if tool_id == "ttw_installer":
|
||||
_, _, binary = _ttw_status_from_config()
|
||||
return binary
|
||||
manifest = _read_manifest(tool_id)
|
||||
bp = manifest.get("binary_path")
|
||||
if bp:
|
||||
p = Path(bp)
|
||||
if p.is_file():
|
||||
return p
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_status(self, defn: ToolDefinition) -> ToolStatus:
|
||||
if defn.tool_id == "ttw_installer":
|
||||
installed, version, binary = _ttw_status_from_config()
|
||||
return ToolStatus(
|
||||
definition=defn,
|
||||
installed=installed,
|
||||
installed_version=version,
|
||||
previous_version=None,
|
||||
binary_path=binary,
|
||||
)
|
||||
manifest = _read_manifest(defn.tool_id)
|
||||
installed_version = manifest.get("installed_version")
|
||||
binary_path_str = manifest.get("binary_path")
|
||||
binary_path = Path(binary_path_str) if binary_path_str else None
|
||||
installed = installed_version is not None and (binary_path is None or binary_path.is_file())
|
||||
return ToolStatus(
|
||||
definition=defn,
|
||||
installed=installed,
|
||||
installed_version=installed_version,
|
||||
previous_version=manifest.get("previous_version"),
|
||||
binary_path=binary_path,
|
||||
)
|
||||
|
||||
def _install_ttw(self) -> Tuple[bool, str]:
|
||||
"""Delegate TTW install to the existing handler."""
|
||||
try:
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
fs = FileSystemHandler()
|
||||
cfg = ConfigHandler()
|
||||
handler = TTWInstallerHandler(
|
||||
steamdeck=False, verbose=False,
|
||||
filesystem_handler=fs, config_handler=cfg,
|
||||
)
|
||||
return handler.install_ttw_installer()
|
||||
except Exception as e:
|
||||
return False, f"TTW install failed: {e}"
|
||||
@@ -5,14 +5,13 @@ This service handles checking for updates via GitHub releases API
|
||||
and coordinating the update process.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
import requests
|
||||
@@ -35,6 +34,7 @@ class UpdateInfo:
|
||||
file_size: Optional[int] = None
|
||||
is_critical: bool = False
|
||||
is_delta_update: bool = False
|
||||
github_download_url: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateService:
|
||||
@@ -101,7 +101,7 @@ class UpdateService:
|
||||
break
|
||||
|
||||
if download_url:
|
||||
# Prefer Nexus CDN for Premium users if this version is available there
|
||||
github_url = download_url
|
||||
nexus_url = self._try_nexus_download_url(latest_version)
|
||||
update_source = "github"
|
||||
if nexus_url:
|
||||
@@ -111,16 +111,13 @@ class UpdateService:
|
||||
else:
|
||||
logger.info("Update source: GitHub Releases (version %s)", latest_version)
|
||||
|
||||
# Determine if this is a delta update
|
||||
is_delta = '.delta' in download_url or 'delta' in download_url.lower()
|
||||
|
||||
# Safety checks to prevent segfault
|
||||
try:
|
||||
# Sanitize string fields
|
||||
safe_version = str(latest_version) if latest_version else ""
|
||||
safe_tag = str(release_data.get('tag_name', ''))
|
||||
safe_date = str(release_data.get('published_at', ''))
|
||||
safe_changelog = str(release_data.get('body', ''))[:1000] # Limit size
|
||||
safe_changelog = str(release_data.get('body', ''))[:1000]
|
||||
safe_url = str(download_url)
|
||||
|
||||
logger.debug(f"Creating UpdateInfo for version {safe_version}")
|
||||
@@ -134,6 +131,7 @@ class UpdateService:
|
||||
file_size=file_size,
|
||||
is_delta_update=is_delta,
|
||||
source=update_source,
|
||||
github_download_url=str(github_url),
|
||||
)
|
||||
|
||||
logger.debug(f"UpdateInfo created successfully")
|
||||
@@ -162,6 +160,13 @@ class UpdateService:
|
||||
and return a CDN download URL for the file matching target_version.
|
||||
Returns None on any failure or if the version is not yet on Nexus.
|
||||
"""
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
if ConfigHandler().get('force_github_updates', False):
|
||||
logger.info("Nexus update source bypassed: force_github_updates is enabled")
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
||||
auth_service = NexusAuthService()
|
||||
@@ -304,33 +309,38 @@ class UpdateService:
|
||||
logger.debug(f"Self-updating enabled for AppImage: {appimage_path}")
|
||||
return True
|
||||
|
||||
def download_update(self, update_info: UpdateInfo,
|
||||
def download_update(self, update_info: UpdateInfo,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
|
||||
"""
|
||||
Download update using full AppImage replacement.
|
||||
|
||||
Since we can't rely on external tools being available, we use a reliable
|
||||
full replacement approach that works on all systems without dependencies.
|
||||
|
||||
Args:
|
||||
update_info: Information about the update to download
|
||||
progress_callback: Optional callback for download progress (bytes_downloaded, total_bytes)
|
||||
|
||||
Returns:
|
||||
Path to downloaded file, or None if download failed
|
||||
Download update AppImage. Falls back to GitHub if the primary source fails.
|
||||
"""
|
||||
try:
|
||||
logger.info("Downloading update %s from %s (full replacement)", update_info.version, update_info.source)
|
||||
result = self._download_update_manual(update_info, progress_callback)
|
||||
if result:
|
||||
logger.info("Update download complete: %s from %s -> %s", update_info.version, update_info.source, result)
|
||||
else:
|
||||
logger.error("Update download failed: %s from %s", update_info.version, update_info.source)
|
||||
logger.info("Downloading update %s from %s (full replacement)", update_info.version, update_info.source)
|
||||
result = self._download_update_manual(update_info, progress_callback)
|
||||
if result:
|
||||
logger.info("Update download complete: %s from %s -> %s", update_info.version, update_info.source, result)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download update: {e}")
|
||||
return None
|
||||
|
||||
# Primary source failed - fall back to GitHub if we came from Nexus
|
||||
if update_info.source == "nexus" and update_info.github_download_url:
|
||||
logger.warning("Nexus download failed, falling back to GitHub")
|
||||
fallback = UpdateInfo(
|
||||
version=update_info.version,
|
||||
tag_name=update_info.tag_name,
|
||||
release_date=update_info.release_date,
|
||||
changelog=update_info.changelog,
|
||||
download_url=update_info.github_download_url,
|
||||
source="github",
|
||||
file_size=update_info.file_size,
|
||||
is_delta_update=False,
|
||||
github_download_url=update_info.github_download_url,
|
||||
)
|
||||
result = self._download_update_manual(fallback, progress_callback)
|
||||
if result:
|
||||
logger.info("Update download complete via GitHub fallback: %s -> %s", update_info.version, result)
|
||||
return result
|
||||
|
||||
logger.error("Update download failed: %s", update_info.version)
|
||||
return None
|
||||
|
||||
def _download_update_manual(self, update_info: UpdateInfo,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
|
||||
@@ -358,104 +368,154 @@ class UpdateService:
|
||||
update_dir = get_jackify_data_dir() / "updates"
|
||||
update_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Nexus delivers a .7z archive; GitHub delivers the AppImage directly.
|
||||
# Detect which we have after download, then handle accordingly.
|
||||
# Saving as .7z avoids any path collision with the final .AppImage name.
|
||||
archive_file = update_dir / f"Jackify-{update_info.version}.7z"
|
||||
temp_file = update_dir / f"Jackify-{update_info.version}.AppImage"
|
||||
|
||||
with open(temp_file, 'wb') as f:
|
||||
|
||||
with open(archive_file, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(downloaded_size, total_size)
|
||||
|
||||
|
||||
if self._is_7z_archive(archive_file):
|
||||
logger.info("Downloaded file is a 7z archive, extracting AppImage")
|
||||
extracted = self._extract_appimage_from_7z(archive_file, update_dir, update_info.version)
|
||||
archive_file.unlink(missing_ok=True)
|
||||
if not extracted:
|
||||
logger.error("Failed to extract AppImage from 7z archive")
|
||||
return None
|
||||
temp_file = extracted
|
||||
else:
|
||||
archive_file.rename(temp_file)
|
||||
|
||||
# Make executable
|
||||
temp_file.chmod(0o755)
|
||||
|
||||
|
||||
logger.info("Update downloaded successfully: %s from %s -> %s", update_info.version, update_info.source, temp_file)
|
||||
return temp_file
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download update manually: {e}")
|
||||
return None
|
||||
|
||||
def _is_7z_archive(self, path: Path) -> bool:
|
||||
"""Detect 7z archive by magic bytes (37 7A BC AF 27 1C)."""
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
magic = f.read(6)
|
||||
return magic == b'7z\xbc\xaf\x27\x1c'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _get_bundled_7z_path(self) -> Optional[Path]:
|
||||
"""Return path to bundled 7z binary (AppImage or dev)."""
|
||||
import os
|
||||
candidates = []
|
||||
appdir = os.environ.get('APPDIR')
|
||||
if appdir:
|
||||
candidates.append(Path(appdir) / 'opt' / 'jackify' / 'tools' / '7z')
|
||||
candidates.append(Path(__file__).parent.parent.parent / 'tools' / '7z')
|
||||
for p in candidates:
|
||||
if p.exists() and os.access(p, os.X_OK):
|
||||
return p
|
||||
return None
|
||||
|
||||
def _extract_appimage_from_7z(self, archive: Path, dest_dir: Path, version: str) -> Optional[Path]:
|
||||
"""Extract AppImage from a 7z archive into dest_dir."""
|
||||
seven_z = self._get_bundled_7z_path()
|
||||
if not seven_z:
|
||||
logger.error("Bundled 7z not found, cannot extract update archive")
|
||||
return None
|
||||
out_path = dest_dir / f"Jackify-{version}.AppImage"
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
tmp_dir = Path(tempfile.mkdtemp(dir=dest_dir))
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(seven_z), 'e', str(archive), f'-o{tmp_dir}', '-y'],
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("7z extraction failed (rc=%d): %s", result.returncode, result.stderr.strip())
|
||||
return None
|
||||
candidates = list(tmp_dir.glob('*.AppImage'))
|
||||
if not candidates:
|
||||
logger.error("No .AppImage found in archive contents: %s",
|
||||
[p.name for p in tmp_dir.iterdir()])
|
||||
return None
|
||||
extracted = candidates[0]
|
||||
logger.debug("Found %s in archive (%d bytes)", extracted.name, extracted.stat().st_size)
|
||||
shutil.move(str(extracted), str(out_path))
|
||||
if not out_path.exists():
|
||||
logger.error("AppImage missing after move to %s", out_path)
|
||||
return None
|
||||
logger.info("Extracted AppImage to %s (%d bytes)", out_path, out_path.stat().st_size)
|
||||
return out_path
|
||||
except Exception as e:
|
||||
logger.error("Exception during 7z extraction: %s", e)
|
||||
return None
|
||||
finally:
|
||||
shutil.rmtree(str(tmp_dir), ignore_errors=True)
|
||||
|
||||
def apply_update(self, new_appimage_path: Path) -> bool:
|
||||
"""
|
||||
Apply update by replacing current AppImage.
|
||||
|
||||
This creates a helper script that waits for Jackify to exit,
|
||||
|
||||
Creates a helper script that waits for Jackify to exit,
|
||||
then replaces the AppImage and restarts it.
|
||||
|
||||
Args:
|
||||
new_appimage_path: Path to downloaded update
|
||||
|
||||
Returns:
|
||||
bool: True if update application was initiated successfully
|
||||
"""
|
||||
current_appimage = get_appimage_path()
|
||||
if not current_appimage:
|
||||
logger.error("Cannot determine current AppImage path")
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
# Create update helper script
|
||||
helper_script = self._create_update_helper(current_appimage, new_appimage_path)
|
||||
|
||||
|
||||
if helper_script:
|
||||
logger.info("Applying update: replacing %s with %s", current_appimage, new_appimage_path)
|
||||
subprocess.Popen(['nohup', 'bash', str(helper_script)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
subprocess.Popen(['nohup', 'bash', str(helper_script)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply update: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _create_update_helper(self, current_appimage: Path, new_appimage: Path) -> Optional[Path]:
|
||||
"""
|
||||
Create helper script for update replacement.
|
||||
|
||||
Args:
|
||||
current_appimage: Path to current AppImage
|
||||
new_appimage: Path to new AppImage
|
||||
|
||||
Returns:
|
||||
Path to helper script, or None if creation failed
|
||||
"""
|
||||
"""Create helper script that replaces the AppImage after Jackify exits."""
|
||||
try:
|
||||
# Create update directory in user's data directory
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
update_dir = get_jackify_data_dir() / "updates"
|
||||
update_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
helper_script = update_dir / "update_helper.sh"
|
||||
|
||||
|
||||
script_content = f'''#!/bin/bash
|
||||
# Jackify Update Helper Script
|
||||
# Safely replaces current AppImage with new version
|
||||
|
||||
CURRENT_APPIMAGE="{current_appimage}"
|
||||
NEW_APPIMAGE="{new_appimage}"
|
||||
TEMP_NAME="$CURRENT_APPIMAGE.updating"
|
||||
|
||||
echo "Jackify Update Helper"
|
||||
echo "Waiting for Jackify to exit..."
|
||||
|
||||
# Wait longer for Jackify to fully exit and unmount
|
||||
sleep 5
|
||||
|
||||
echo "Validating new AppImage..."
|
||||
|
||||
# Validate new AppImage exists and is executable
|
||||
if [ ! -f "$NEW_APPIMAGE" ]; then
|
||||
echo "ERROR: New AppImage not found: $NEW_APPIMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test that new AppImage can execute --version
|
||||
if ! timeout 10 "$NEW_APPIMAGE" --version >/dev/null 2>&1; then
|
||||
echo "ERROR: New AppImage failed validation test"
|
||||
exit 1
|
||||
@@ -464,34 +524,24 @@ fi
|
||||
echo "New AppImage validated successfully"
|
||||
echo "Performing safe replacement..."
|
||||
|
||||
# Backup current version
|
||||
if [ -f "$CURRENT_APPIMAGE" ]; then
|
||||
cp "$CURRENT_APPIMAGE" "$CURRENT_APPIMAGE.backup"
|
||||
fi
|
||||
|
||||
# Safe replacement: copy to temp name first, then atomic move
|
||||
if cp "$NEW_APPIMAGE" "$TEMP_NAME"; then
|
||||
chmod +x "$TEMP_NAME"
|
||||
|
||||
# Atomic move to replace
|
||||
if mv "$TEMP_NAME" "$CURRENT_APPIMAGE"; then
|
||||
echo "Update completed successfully!"
|
||||
|
||||
# Clean up
|
||||
rm -f "$NEW_APPIMAGE"
|
||||
rm -f "$CURRENT_APPIMAGE.backup"
|
||||
|
||||
# Restart Jackify
|
||||
echo "Restarting Jackify..."
|
||||
sleep 1
|
||||
exec "$CURRENT_APPIMAGE"
|
||||
else
|
||||
echo "ERROR: Failed to move updated AppImage"
|
||||
rm -f "$TEMP_NAME"
|
||||
# Restore backup
|
||||
if [ -f "$CURRENT_APPIMAGE.backup" ]; then
|
||||
mv "$CURRENT_APPIMAGE.backup" "$CURRENT_APPIMAGE"
|
||||
echo "Restored original AppImage"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
@@ -500,19 +550,16 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up this script
|
||||
rm -f "{helper_script}"
|
||||
'''
|
||||
|
||||
|
||||
with open(helper_script, 'w') as f:
|
||||
f.write(script_content)
|
||||
|
||||
# Make executable
|
||||
|
||||
helper_script.chmod(0o755)
|
||||
|
||||
logger.debug(f"Created update helper script: {helper_script}")
|
||||
return helper_script
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create update helper script: {e}")
|
||||
return None
|
||||
|
||||
@@ -6,7 +6,7 @@ import re
|
||||
from typing import Optional
|
||||
|
||||
# Matches CC content file names: ccXXXsse001-name.bsa/esm/esl/esp, ccXXXfo4001-name.ba2, etc.
|
||||
# No leading \b — filenames often appear with a Data_ prefix (Data_ccbgssse019-...)
|
||||
# No leading \b - filenames often appear with a Data_ prefix (Data_ccbgssse019-...)
|
||||
# where _ is a word char and would prevent \b from matching.
|
||||
_CC_FILE_RE = re.compile(
|
||||
r'cc[a-z]{2,8}\d{3,4}[-\w]*\.(?:bsa|esm|esl|esp|ba2)',
|
||||
@@ -32,3 +32,28 @@ def extract_cc_filename(line: str) -> Optional[str]:
|
||||
"""Return the CC filename from a line, or None if not found."""
|
||||
m = _CC_FILE_RE.search(line)
|
||||
return m.group(0) if m else None
|
||||
|
||||
|
||||
# Files that only exist inside the Skyrim SE Creation Kit install.
|
||||
# Used to detect modlists that require the CK as a game file source.
|
||||
_CK_INDICATORS = (
|
||||
'creationkit',
|
||||
'papyrus compiler',
|
||||
'scriptcompile',
|
||||
'lipgen',
|
||||
'assetwatcher',
|
||||
'havokbehaviorpostprocess',
|
||||
'skyrimreservedaddonindexes',
|
||||
'p4com64',
|
||||
'lex_ssce',
|
||||
)
|
||||
|
||||
|
||||
def is_creation_kit_missing_error(line: str) -> bool:
|
||||
"""Return True if line indicates a missing Creation Kit file (GameFileSource)."""
|
||||
if not line:
|
||||
return False
|
||||
normalized = line.strip().lower()
|
||||
if 'gamefilesource' not in normalized:
|
||||
return False
|
||||
return any(ind in normalized for ind in _CK_INDICATORS)
|
||||
|
||||
@@ -53,7 +53,7 @@ _TYPE_MAP = {
|
||||
suggestion="Check your internet connection and retry.",
|
||||
solutions=[
|
||||
"Verify your internet connection.",
|
||||
"Re-run the install — Wabbajack resumes from where it stopped.",
|
||||
"Re-run the install - Wabbajack resumes from where it stopped.",
|
||||
"Check if Nexus Mods is reachable at nexusmods.com.",
|
||||
"Disable VPN or proxy if active.",
|
||||
],
|
||||
@@ -84,7 +84,7 @@ _TYPE_MAP = {
|
||||
"archive_corrupt": lambda msg, ctx: InstallError(
|
||||
"Corrupted Archive",
|
||||
msg,
|
||||
suggestion="Re-run the install — Wabbajack will re-download and re-verify the file.",
|
||||
suggestion="Re-run the install - Wabbajack will re-download and re-verify the file.",
|
||||
solutions=[
|
||||
"Re-run the install.",
|
||||
"Check available disk space (partial downloads appear corrupt).",
|
||||
@@ -99,7 +99,7 @@ _TYPE_MAP = {
|
||||
solutions=[
|
||||
"Verify the modlist name is correct.",
|
||||
"Ensure the target game is installed.",
|
||||
"Re-run — the modlist index may have been temporarily unavailable.",
|
||||
"Re-run - the modlist index may have been temporarily unavailable.",
|
||||
],
|
||||
technical=_ctx_detail(ctx),
|
||||
),
|
||||
@@ -108,7 +108,7 @@ _TYPE_MAP = {
|
||||
msg,
|
||||
suggestion="Re-run the install to re-download any failed files.",
|
||||
solutions=[
|
||||
"Re-run the install — Wabbajack resumes and re-validates.",
|
||||
"Re-run the install - Wabbajack resumes and re-validates.",
|
||||
"Check available disk space.",
|
||||
"Check Modlist_Install_workflow.log for specific failures.",
|
||||
],
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user