mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-13 22:43:42 +02:00
Release v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support
This commit is contained in:
+3
-4
@@ -20,10 +20,12 @@ core/modules/__pycache__/
|
||||
|
||||
# Jackify engine temp and output files
|
||||
jackify-install-engine/temp/
|
||||
jackify/engine/temp/
|
||||
jackify-install-engine/*.log
|
||||
jackify-install-engine/*.dds
|
||||
|
||||
# jackify-engine is now distributed via GitHub releases, not bundled in the repo
|
||||
jackify/engine/
|
||||
|
||||
# Editor/OS temp files
|
||||
*.tmp
|
||||
*.bak
|
||||
@@ -43,6 +45,3 @@ requirements-packaging.txt
|
||||
# Development logs
|
||||
logs/
|
||||
|
||||
# NOTE: .dll, .json, .deps.json, .runtimeconfig.json in jackify-install-engine/
|
||||
# are currently tracked because it's unclear if they are required for runtime/distribution
|
||||
# or are build artifacts. Do not ignore until this is confirmed.
|
||||
+53
-1
@@ -1,5 +1,57 @@
|
||||
# Jackify Changelog
|
||||
|
||||
## v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support
|
||||
**Release Date:** TBD
|
||||
|
||||
### Engine Choice and Tools Hub
|
||||
- jackify-engine is no longer stored in the Jackify repository. It is fetched from GitHub during the AppImage build and can be updated independently via the Jackify Tools Hub without requiring a new Jackify release.
|
||||
- EXPERIMENTAL: Choose between jackify-engine (hash-matched with Wabbajack, proven) and CLF3 (faster install, not hash-matched with Wabbajack). Use the **Use Experimental Engine** checkbox on the Install Modlist screen, or switch permanently via the Tools Hub.
|
||||
- A new Tools Hub available from Additional Tasks: download, update, downgrade a growing list of additional tools such as TTW Linux Installer and Radium Textures, as well as install engines.
|
||||
|
||||
### NXM Link Handling
|
||||
- Jackify can become the registered nxm:// handler for your system.
|
||||
- Clicking a Nexus "Mod Manager Download" or "Vortex" button link while MO2 is open routes the download directly to MO2 in your active modlist prefix.
|
||||
- If MO2 is not running, the link opens Jackify to a standalone dialog to select which modlist to send the download to.
|
||||
|
||||
### .NET Script Framework (NSF) / Custom Skills Framework (CSF) Support
|
||||
- Skyrim 1.5.97 Modlists using NetScriptFramework and Custom Skills Framework are now detected automatically. dotnet48 is installed via the bundled winetricks verb after Wine Mono is removed, then the prefix is configured so CSF's menu loads correctly.
|
||||
|
||||
### Native Wine Component Installer
|
||||
- Direct-source installation for d3dcompiler, xact, vcrun2022, vcrun2012, and dotnet 6/7/8. Components are downloaded directly from Microsoft/Mozilla and extracted or installed.
|
||||
- winetricks and protontricks remain as fallback for any component not handled natively. All being well, first step towards removing the hard requirement of protontricks.
|
||||
|
||||
### Install Verification
|
||||
- After install and configure workflows complete, Jackify verifies that the Wine prefix, Steam shortcut, and Proton compatibility tool are all correctly in place. Results are shown in the success dialog with a breakdown of any issues found. Up to 100 checks are performed depending on the game type and modlist contents.
|
||||
- Verification can be run on an existing Modlist at any time from the Additional Tools menu.
|
||||
|
||||
### Diagnostics Bundle
|
||||
- Export a diagnostics bundle (logs, system info, config summary) from Additional Tasks. Attach to bug reports to speed up support.
|
||||
|
||||
### GOG and Epic Games via Heroic
|
||||
- Both jackify-engine and Jackify should now detect games installed via Heroic Launcher (GOG and Epic), including both native and Flatpak Heroic installations - note that the Modlist itself HAS to support the GOG/Epic version of the base game, and not many do - there is nothing I can do about this.
|
||||
- GOG/Epic versions of FO3/FNV should now be usable for TTW generation.
|
||||
|
||||
### Fixes
|
||||
- Configure Existing Modlist now correctly updates `STEAM_COMPAT_MOUNTS` in your Steam shortcut's launch options to include MO2's download_directory so that it is accessible inside MO2.
|
||||
- Jackify Download Manager manual download columns are now sortable, including numeric sort for file sizes.
|
||||
- SD card game path detection no longer incorrectly requires a `/Games/` component in the path.
|
||||
- Download directory should now correctly show the in-Proton path and not the Linux FS path, which was corrupting the download_directory path in MO2.
|
||||
- Proton version selection now scans installed versions dynamically. Proton 11 (and any future version) is found and ranked correctly.
|
||||
- Steam grid artwork: if a modlist ships a partial SteamIcons directory, missing slots are now filled from SteamGridDB rather than left blank.
|
||||
- FO4VR: vcrun2012 applied automatically during tool configuration, as per community suggestion.
|
||||
- Fallout 4: Creation Kit missing-files error is now detected correctly, rather than stating Skyrim CK is missing.
|
||||
- Improved cancellation during Wine component installation. Now terminates winetricks and Wine subprocesses immediately rather than waiting for them to finish.
|
||||
- Fixed: Manual downloads could open the same download link a second time within seconds if the file was still being written to disk when the watcher first detected it.
|
||||
- Fixed: Files that finished downloading were sometimes not picked up by the watch folder scanner, leaving items stuck on "pending" and requiring manual intervention.
|
||||
- Added a "Scan Now" button to the manual download dialog to force an immediate re-scan of the watch directory without waiting for the next automatic poll.
|
||||
|
||||
### Engine (0.5.6)
|
||||
- Heroic Launcher game detection (GOG + Epic, native and Flatpak).
|
||||
- Fixed case-duplicate archive entries causing hash-mismatch failures on Linux when archives contain files differing only in case.
|
||||
- Fixed garbled progress lines where tails of longer messages bled through on carriage-return output.
|
||||
|
||||
---
|
||||
|
||||
## v0.6.0.1 - Hotfix
|
||||
**Release Date:** 24/04/26
|
||||
|
||||
@@ -41,7 +93,7 @@
|
||||
**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.
|
||||
- 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.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
# Jackify
|
||||
|
||||
Jackify is a Linux application for installing and configuring Wabbajack modlists on Linux and Steam Deck. It provides a complete end-to-end workflow — downloading, installing, Steam shortcut creation, Proton prefix setup, and post-install configuration — through both a GUI and a full-featured CLI.
|
||||
Jackify is a Linux application for installing and configuring Wabbajack modlists on Linux and Steam Deck. It provides a complete end-to-end workflow (downloading, installing, Steam shortcut creation, Proton prefix setup, and post-install configuration) through both a GUI and a full-featured CLI.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -28,17 +28,17 @@ Jackify is a Linux application for installing and configuring Wabbajack modlists
|
||||
- **Best Effort Support**: Support is provided on a best-effort basis through community channels
|
||||
- **Data Safety**: Always back up your important data before using Jackify
|
||||
- **System Compatibility**: Functionality on your specific system is not guaranteed
|
||||
- **A successful installation does not guarantee a working modlist**: Linux introduces hardware, driver, and system-specific variables that cannot be accounted for. If your modlist installs successfully but does not run correctly, seek help in [#unofficial-linux-help](https://discord.gg/wabbajack) on the Wabbajack Discord — do not contact the modlist author unless they explicitly support Linux
|
||||
- **A successful installation does not guarantee a working modlist**: Linux introduces hardware, driver, and system-specific variables that cannot be accounted for. If your modlist installs successfully but does not run correctly, seek help in [#unofficial-linux-help](https://discord.gg/wabbajack) on the Wabbajack Discord. Do not contact the modlist author unless they explicitly support Linux.
|
||||
- **Not all modlists can be fully automated**: Some modlists (e.g. Fallout New Vegas lists) require manual steps that Jackify cannot automate (or I have not automated yet). Always check the Install Guide of the Modlist itself to see what could be needed.
|
||||
- **Most Modlists are not officially supported on Linux**: Jackify makes a best effort to get modlists running, but compatibility is not guaranteed and will vary between modlists, hardware, and system configuration
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux system (most modern distributions will work)
|
||||
- Steam installed and configured — **the Snap version of Steam is not supported**
|
||||
- **Protontricks** — required for modlist configuration
|
||||
- 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
|
||||
- **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 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
|
||||
@@ -77,7 +77,7 @@ For a full step-by-step guide with screenshots, see the [User Guide](https://git
|
||||
- Oblivion
|
||||
- Starfield
|
||||
- Enderal
|
||||
- Other games (Cyberpunk 2077, Baldur's Gate 3, and more — download and install support only for now - full automatioin coming in the future)
|
||||
- Other games (Cyberpunk 2077, Baldur's Gate 3, and more): download and install support only for now; full automation coming in the future
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -106,7 +106,7 @@ The original bash scripts and step-by-step manual installation guides are preser
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GPLv3 License — see the LICENSE file for details.
|
||||
This project is licensed under the GPLv3 License. See the LICENSE file for details.
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
+1
-1
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
||||
Wabbajack modlists natively on Linux systems.
|
||||
"""
|
||||
|
||||
__version__ = "0.6.0.1"
|
||||
__version__ = "0.7.0"
|
||||
|
||||
@@ -22,11 +22,64 @@ logger = logging.getLogger(__name__)
|
||||
class ModlistOperationsConfigurationCLIMixin:
|
||||
"""Mixin providing CLI configuration phase methods."""
|
||||
|
||||
def _clf3_fetch_wabbajack(self, engine_path: str, machine_name: str, engine_dir: str) -> "str | None":
|
||||
"""
|
||||
Resolve a gallery machine name to a local .wabbajack file for CLF3.
|
||||
|
||||
Looks up the CDN download URL from the metadata cache, then runs
|
||||
`clf3 fetch` to download the file. Returns the local path on success,
|
||||
or None (with an error printed) on failure.
|
||||
"""
|
||||
from jackify.shared.paths import get_jackify_data_dir, get_jackify_downloads_dir
|
||||
import json as _json
|
||||
|
||||
list_id = machine_name.split('/')[-1] if '/' in machine_name else machine_name
|
||||
local_path = str(get_jackify_downloads_dir() / f"{list_id}.wabbajack")
|
||||
|
||||
if os.path.isfile(local_path):
|
||||
self.logger.info("CLF3: using cached wabbajack file at %s", local_path)
|
||||
return local_path
|
||||
|
||||
download_url = None
|
||||
cache_file = get_jackify_data_dir() / "modlist-cache" / "metadata" / "modlist_metadata.json"
|
||||
if cache_file.is_file():
|
||||
try:
|
||||
data = _json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
for entry in data.get("modlists", []):
|
||||
if entry.get("namespacedName") == machine_name or entry.get("machineURL") == list_id:
|
||||
download_url = (entry.get("links") or {}).get("download")
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.warning("CLF3: could not read metadata cache: %s", e)
|
||||
|
||||
if not download_url:
|
||||
print(
|
||||
f"{COLOR_ERROR}CLF3 requires a download URL for '{machine_name}' but none was found in the "
|
||||
f"gallery cache. Refresh the modlist gallery or use a local .wabbajack file.{COLOR_RESET}"
|
||||
)
|
||||
return None
|
||||
|
||||
print(f"{COLOR_INFO}Downloading modlist file via CLF3...{COLOR_RESET}")
|
||||
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
|
||||
fetch_cmd = [engine_path, "fetch", download_url, "--output", local_path]
|
||||
self.logger.debug("CLF3 fetch command: %s", " ".join(fetch_cmd))
|
||||
fetch_env = get_clean_subprocess_env({})
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
fetch_result = subprocess.run(
|
||||
fetch_cmd, capture_output=True, text=True, env=fetch_env, cwd=engine_dir
|
||||
)
|
||||
if fetch_result.returncode != 0:
|
||||
err = fetch_result.stderr.strip() or fetch_result.stdout.strip() or "unknown error"
|
||||
print(f"{COLOR_ERROR}Failed to download modlist file:\n{err}{COLOR_RESET}")
|
||||
return None
|
||||
|
||||
print(f"{COLOR_INFO}Modlist file ready.{COLOR_RESET}")
|
||||
return local_path
|
||||
|
||||
def configuration_phase(self):
|
||||
"""
|
||||
Run the configuration phase: execute the Linux-native Jackify Install Engine.
|
||||
Run the configuration phase: execute the active install engine.
|
||||
"""
|
||||
from .modlist_operations import get_jackify_engine_path
|
||||
|
||||
print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}")
|
||||
start_time = time.time()
|
||||
@@ -92,11 +145,20 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
api_key = current_api_key or self.context.get('nexus_api_key')
|
||||
oauth_info = current_oauth_info or self.context.get('nexus_oauth_info')
|
||||
|
||||
engine_path = get_jackify_engine_path()
|
||||
engine_dir = os.path.dirname(engine_path)
|
||||
if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK):
|
||||
print(f"{COLOR_ERROR}Jackify Install Engine not found or not executable at: {engine_path}{COLOR_RESET}")
|
||||
from jackify.backend.services.engine_invoker import (
|
||||
get_active_engine_id, get_engine_path, build_install_command,
|
||||
resolve_game_dir, resolve_game_location, is_clf3_active,
|
||||
)
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
|
||||
engine_id = get_active_engine_id()
|
||||
engine_path = get_engine_path(engine_id)
|
||||
if not engine_path or not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK):
|
||||
print(f"{COLOR_ERROR}Install engine not found or not executable: {engine_id} ({engine_path or 'path unknown'}){COLOR_RESET}")
|
||||
return
|
||||
engine_dir = os.path.dirname(engine_path)
|
||||
clf3_mode = is_clf3_active()
|
||||
|
||||
if os.environ.get('JACKIFY_GUI_MODE') == '1':
|
||||
if not self.context.get('modlist_source'):
|
||||
@@ -105,60 +167,79 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
self.logger.error("modlist_value is missing in context for GUI workflow!")
|
||||
return
|
||||
|
||||
cmd = [engine_path, 'install', '--show-file-progress']
|
||||
modlist_value = self.context.get('modlist_value')
|
||||
if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value):
|
||||
cmd += ['-w', modlist_value]
|
||||
elif modlist_value:
|
||||
cmd += ['-m', modlist_value]
|
||||
elif self.context.get('machineid'):
|
||||
cmd += ['-m', self.context['machineid']]
|
||||
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
||||
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
modlist_value = self.context.get('modlist_value') or self.context.get('machineid', '')
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
if debug_mode:
|
||||
cmd.append('--debug')
|
||||
|
||||
game_dir = None
|
||||
if clf3_mode:
|
||||
game_type = self.context.get('game_type')
|
||||
location = resolve_game_location(game_type)
|
||||
if location:
|
||||
game_dir, game_store = location
|
||||
if game_store != 'steam':
|
||||
store_label = {'gog': 'GOG', 'epic': 'Epic Games'}.get(game_store, game_store)
|
||||
print(
|
||||
f"[WARN] Game detected from {store_label}, not Steam. "
|
||||
"Most Wabbajack modlists require the Steam version. "
|
||||
"If the install fails with hash errors, a store version mismatch is likely the cause."
|
||||
)
|
||||
else:
|
||||
self.logger.warning("CLF3: could not resolve game directory for game_type=%s", game_type)
|
||||
|
||||
if not (modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value)):
|
||||
modlist_value = self._clf3_fetch_wabbajack(
|
||||
engine_path, modlist_value, engine_dir
|
||||
)
|
||||
if modlist_value is None:
|
||||
return
|
||||
|
||||
cmd = build_install_command(
|
||||
engine_id=engine_id,
|
||||
engine_path=engine_path,
|
||||
wabbajack=modlist_value,
|
||||
install_dir=install_dir_str,
|
||||
downloads_dir=download_dir_str,
|
||||
game_dir=game_dir,
|
||||
install_mode='file' if (modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value)) else 'online',
|
||||
debug=debug_mode,
|
||||
)
|
||||
if debug_mode and not clf3_mode:
|
||||
self.logger.info("Adding --debug flag to jackify-engine")
|
||||
writeback_path = str(auth_service.get_token_writeback_path())
|
||||
writeback_path = str(auth_service.get_token_writeback_path()) if not clf3_mode else None
|
||||
original_env_values = {
|
||||
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
|
||||
'NEXUS_OAUTH_TOKEN': os.environ.get('NEXUS_OAUTH_TOKEN'),
|
||||
'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
|
||||
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
|
||||
self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)")
|
||||
if clf3_mode:
|
||||
if api_key:
|
||||
os.environ['NEXUS_OAUTH_TOKEN'] = api_key
|
||||
else:
|
||||
if writeback_path:
|
||||
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
|
||||
if api_key:
|
||||
os.environ['NEXUS_API_KEY'] = api_key
|
||||
elif api_key:
|
||||
os.environ['NEXUS_API_KEY'] = api_key
|
||||
self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)")
|
||||
else:
|
||||
if 'NEXUS_API_KEY' in os.environ:
|
||||
del os.environ['NEXUS_API_KEY']
|
||||
if 'NEXUS_OAUTH_INFO' in os.environ:
|
||||
del os.environ['NEXUS_OAUTH_INFO']
|
||||
if 'NEXUS_OAUTH_CLIENT_ID' in os.environ:
|
||||
del os.environ['NEXUS_OAUTH_CLIENT_ID']
|
||||
self.logger.debug(f"No Nexus auth available, cleared inherited env vars")
|
||||
if oauth_info:
|
||||
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
|
||||
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
|
||||
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
|
||||
self.logger.debug("Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID for engine")
|
||||
elif not api_key:
|
||||
for key in ('NEXUS_API_KEY', 'NEXUS_OAUTH_INFO', 'NEXUS_OAUTH_CLIENT_ID'):
|
||||
os.environ.pop(key, None)
|
||||
self.logger.debug("No Nexus auth available, cleared inherited env vars")
|
||||
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
|
||||
|
||||
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
|
||||
self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.")
|
||||
|
||||
self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.")
|
||||
self.logger.info("Environment prepared for %s install process.", engine_id)
|
||||
self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}")
|
||||
self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') else '[NOT SET]'}")
|
||||
|
||||
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
|
||||
print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}")
|
||||
engine_label = "CLF3" if clf3_mode else "Jackify Install Engine"
|
||||
print(f"{COLOR_INFO}Launching {engine_label} with command:{COLOR_RESET} {pretty_cmd}")
|
||||
|
||||
from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit
|
||||
success, old_limit, new_limit, message = increase_file_descriptor_limit()
|
||||
@@ -169,16 +250,80 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
|
||||
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
|
||||
clean_env = get_clean_subprocess_env()
|
||||
self._current_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=False,
|
||||
env=clean_env,
|
||||
cwd=engine_dir,
|
||||
)
|
||||
proc = self._current_process
|
||||
|
||||
if clf3_mode:
|
||||
import threading as _threading
|
||||
from jackify.backend.handlers.progress_parser_clf3 import CLF3ProgressStateManager
|
||||
clf3_parser = CLF3ProgressStateManager()
|
||||
self._current_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=False,
|
||||
env=clean_env,
|
||||
cwd=engine_dir,
|
||||
)
|
||||
proc = self._current_process
|
||||
|
||||
def _drain_stderr():
|
||||
for _ in proc.stderr:
|
||||
pass
|
||||
|
||||
stderr_thread = _threading.Thread(target=_drain_stderr, daemon=True)
|
||||
stderr_thread.start()
|
||||
|
||||
_inline_active = False
|
||||
_last_phase = ''
|
||||
for raw in proc.stdout:
|
||||
line = raw.decode('utf-8', errors='replace').rstrip('\r\n')
|
||||
if not line.strip():
|
||||
continue
|
||||
if line.strip().startswith('{'):
|
||||
prev_phase = clf3_parser.get_state().phase_name
|
||||
changed = clf3_parser.process_line(line)
|
||||
if changed:
|
||||
state = clf3_parser.get_state()
|
||||
new_phase = state.phase_name or ''
|
||||
if new_phase != _last_phase:
|
||||
if _inline_active:
|
||||
print()
|
||||
_inline_active = False
|
||||
_last_phase = new_phase
|
||||
print(f"\n=== {new_phase} ===")
|
||||
msg = state.message
|
||||
if state.phase_name == "Queuing" and state.phase_max_steps:
|
||||
msg = f"Queuing archives: {state.phase_step}/{state.phase_max_steps}"
|
||||
if msg:
|
||||
print(f"\r{msg}\033[K", end='', flush=True)
|
||||
_inline_active = True
|
||||
else:
|
||||
if _inline_active:
|
||||
print()
|
||||
_inline_active = False
|
||||
print(line)
|
||||
|
||||
if _inline_active:
|
||||
print()
|
||||
stderr_thread.join(timeout=2)
|
||||
proc.wait()
|
||||
self._current_process = None
|
||||
if proc.returncode != 0:
|
||||
print(f"{COLOR_ERROR}CLF3 exited with code {proc.returncode}.{COLOR_RESET}")
|
||||
self.logger.error("CLF3 exited with code %d.", proc.returncode)
|
||||
return
|
||||
self.logger.info("CLF3 completed successfully.")
|
||||
|
||||
if not clf3_mode:
|
||||
self._current_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=False,
|
||||
env=clean_env,
|
||||
cwd=engine_dir,
|
||||
)
|
||||
proc = self._current_process
|
||||
|
||||
def _write_stdin(payload: str) -> bool:
|
||||
if not proc.stdin or proc.poll() is not None:
|
||||
@@ -282,12 +427,14 @@ 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}.")
|
||||
return
|
||||
self.logger.info(f"Engine completed with code {proc.returncode}.")
|
||||
if writeback_path:
|
||||
auth_service.apply_token_writeback(writeback_path)
|
||||
if not clf3_mode:
|
||||
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}.")
|
||||
return
|
||||
self.logger.info(f"Engine completed with code {proc.returncode}.")
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
print(f"{COLOR_ERROR}Error running Jackify Install Engine: {error_message}{COLOR_RESET}\n")
|
||||
@@ -360,6 +507,28 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
elapsed = int(time.time() - start_time)
|
||||
print(f"\nElapsed time: {elapsed//3600:02d}:{(elapsed%3600)//60:02d}:{elapsed%60:02d} (hh:mm:ss)\n")
|
||||
print(f"{COLOR_INFO}Your modlist has been installed to: {install_dir_str}{COLOR_RESET}\n")
|
||||
|
||||
try:
|
||||
from jackify.backend.utils.modlist_meta import write_modlist_meta
|
||||
_meta_game_type = self.context.get('detected_game') or self.context.get('special_game_type')
|
||||
write_modlist_meta(
|
||||
install_dir_str,
|
||||
self.context.get('modlist_name', ''),
|
||||
_meta_game_type,
|
||||
install_mode=self.context.get('install_mode', 'online'),
|
||||
)
|
||||
except Exception as _meta_err:
|
||||
self.logger.debug("Modlist meta write skipped: %s", _meta_err)
|
||||
|
||||
try:
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
_ini_path = Path(install_dir_str) / "ModOrganizer.ini"
|
||||
_modlist_sdcard = install_dir_str.startswith('/run/media/')
|
||||
PathHandler().set_download_directory(_ini_path, download_dir_str, _modlist_sdcard)
|
||||
self.logger.info("Set download_directory in ModOrganizer.ini: %s", download_dir_str)
|
||||
except Exception as _ini_err:
|
||||
self.logger.warning("Could not set download_directory in ModOrganizer.ini: %s", _ini_err)
|
||||
|
||||
if self.context.get('machineid') != 'Tuxborn/Tuxborn':
|
||||
print(f"{COLOR_WARNING}Only Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, and Oblivion Remastered modlists are compatible with Jackify's post-install configuration. Any modlist can be downloaded/installed, but only these games are supported for automated configuration.{COLOR_RESET}")
|
||||
|
||||
@@ -500,8 +669,11 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
_is_steamdeck = True
|
||||
except Exception:
|
||||
_is_steamdeck = False
|
||||
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
|
||||
download_dir = resolve_mo2_download_dir(Path(install_dir_str))
|
||||
result = prefix_service.run_working_workflow(
|
||||
shortcut_name, install_dir_str, mo2_exe_path, progress_callback, steamdeck=_is_steamdeck
|
||||
shortcut_name, install_dir_str, mo2_exe_path, progress_callback,
|
||||
steamdeck=_is_steamdeck, download_dir=download_dir,
|
||||
)
|
||||
|
||||
if isinstance(result, tuple) and len(result) == 4:
|
||||
@@ -642,7 +814,7 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
modlist_context = ModlistContext(
|
||||
name=shortcut_name,
|
||||
install_dir=Path(install_dir_str),
|
||||
download_dir=Path(install_dir_str) / "downloads",
|
||||
download_dir=Path(download_dir_str),
|
||||
game_type=self.context.get('detected_game', 'Unknown'),
|
||||
nexus_api_key='',
|
||||
modlist_value=self.context.get('modlist_value', ''),
|
||||
@@ -669,6 +841,21 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
if configuration_success:
|
||||
self.logger.info("Post-installation configuration completed successfully")
|
||||
print(f"{COLOR_INFO}Core configuration complete. Checking post-install automation...{COLOR_RESET}")
|
||||
|
||||
if getattr(modlist_context, 'enb_detected', False):
|
||||
print(f"\n{COLOR_WARNING}ENB Detected{COLOR_RESET}")
|
||||
from jackify.backend.data.modlist_proton_requirements import get_proton_requirement
|
||||
_proton_req = get_proton_requirement(shortcut_name)
|
||||
if _proton_req:
|
||||
print(f"{COLOR_WARNING}This modlist requires {_proton_req['required']} for ENB compatibility.{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}{_proton_req['note']}{COLOR_RESET}")
|
||||
else:
|
||||
print(f"{COLOR_INFO}If you plan on using ENB as part of this modlist, you will need one of the following Proton versions:{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO} (In order of recommendation){COLOR_RESET}")
|
||||
print(f"{COLOR_INFO} - Proton-CachyOS{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO} - GE-Proton{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO} - Proton 9 (Valve){COLOR_RESET}")
|
||||
print(f"{COLOR_WARNING} Valve Proton 10 has known ENB compatibility issues.{COLOR_RESET}")
|
||||
try:
|
||||
# Ensure CLI install flow gets the same VNV automation behavior as GUI.
|
||||
from jackify.backend.services.vnv_integration_helper import (
|
||||
@@ -747,7 +934,83 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
except Exception as ttw_err:
|
||||
self.logger.error("TTW post-install prompt failed: %s", ttw_err, exc_info=True)
|
||||
print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}")
|
||||
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
|
||||
try:
|
||||
from jackify.backend.handlers.modlist_fixup_handler import (
|
||||
check_jcontainers_needs_fix,
|
||||
apply_jcontainers_fix,
|
||||
)
|
||||
_jc_game_type = detected_game or self.context.get('detected_game', '')
|
||||
needs_fix = check_jcontainers_needs_fix(Path(install_dir_str), _jc_game_type)
|
||||
if needs_fix:
|
||||
print(f"\n{COLOR_WARNING}JContainers Compatibility Fix{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}The mod JContainers has been detected. The Nexusmods version is known to cause crashes on Linux/Proton.{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}A fixed version is available from the mod's GitHub page. The original DLL will be backed up first.{COLOR_RESET}")
|
||||
try:
|
||||
user_input = input(f"{COLOR_PROMPT}Apply JContainers fix now? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
user_input = "n"
|
||||
if user_input in ("", "y", "yes"):
|
||||
apply_jcontainers_fix(Path(install_dir_str), _jc_game_type)
|
||||
print(f"{COLOR_INFO}JContainers fix applied.{COLOR_RESET}")
|
||||
else:
|
||||
print(f"{COLOR_INFO}JContainers fix skipped.{COLOR_RESET}")
|
||||
except Exception as jc_err:
|
||||
self.logger.warning("JContainers fix check failed (non-fatal): %s", jc_err)
|
||||
try:
|
||||
from jackify.backend.services.install_verifier_service import (
|
||||
run_install_verification, resolve_pfx_for_appid, _load_verifier as _lv_final,
|
||||
)
|
||||
from jackify.frontends.cli.ui.indeterminate_status import CliIndeterminateStatus
|
||||
import threading
|
||||
_pfx = resolve_pfx_for_appid(str(app_id)) if app_id else None
|
||||
if _pfx and _pfx.is_dir():
|
||||
_vmod_final = _lv_final()
|
||||
_norm_gt = _vmod_final.detect_game_type(Path(install_dir_str))
|
||||
_verif_result = [None]
|
||||
_spinner = CliIndeterminateStatus()
|
||||
_spinner.set("Running install verification...")
|
||||
def _verif_worker():
|
||||
_verif_result[0] = run_install_verification(
|
||||
_pfx,
|
||||
Path(install_dir_str),
|
||||
_norm_gt,
|
||||
str(app_id) if app_id else "",
|
||||
shortcut_name,
|
||||
)
|
||||
_t = threading.Thread(target=_verif_worker, daemon=True)
|
||||
_t.start()
|
||||
_t.join()
|
||||
_spinner.stop()
|
||||
r = _verif_result[0]
|
||||
if r is not None:
|
||||
n_pass = len(r.passes) if hasattr(r, 'passes') else 0
|
||||
n_warn = len(r.warnings) if hasattr(r, 'warnings') else 0
|
||||
n_fail = len(r.failures) if hasattr(r, 'failures') else 0
|
||||
_total = n_pass + n_warn + n_fail
|
||||
print(f"\n--- Install Verification ---")
|
||||
print(f" {n_pass} passed, {n_warn} warnings, {n_fail} failed (of {_total} checks)")
|
||||
for msg in (r.failures if hasattr(r, 'failures') else []):
|
||||
print(f"{COLOR_ERROR} [FAIL] {msg}{COLOR_RESET}")
|
||||
for msg in (r.warnings if hasattr(r, 'warnings') else []):
|
||||
print(f"{COLOR_WARNING} [WARN] {msg}{COLOR_RESET}")
|
||||
if not n_fail and not n_warn:
|
||||
print(f"{COLOR_SUCCESS} All checks passed.{COLOR_RESET}")
|
||||
print()
|
||||
except Exception as verif_err:
|
||||
print(f"{COLOR_WARNING}[WARN] Install verifier failed: {verif_err}{COLOR_RESET}")
|
||||
self.logger.warning("Install verification failed: %s", verif_err, exc_info=True)
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
print("")
|
||||
print("")
|
||||
print("=" * 35)
|
||||
print("= Configuration phase complete =")
|
||||
print("=" * 35)
|
||||
print("")
|
||||
print("Modlist Install and Configuration complete!")
|
||||
print(f" You should now be able to Launch '{shortcut_name}' through Steam")
|
||||
print(" Congratulations and enjoy the game!")
|
||||
print("")
|
||||
print(f"Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log")
|
||||
else:
|
||||
print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}")
|
||||
self.logger.warning("Post-installation configuration had issues")
|
||||
|
||||
@@ -10,23 +10,16 @@ class ModlistOperationsConfigurationGUIMixin:
|
||||
|
||||
def configuration_phase_gui_mode(self, context,
|
||||
progress_callback=None,
|
||||
manual_steps_callback=None,
|
||||
completion_callback=None):
|
||||
"""
|
||||
GUI-friendly configuration phase that uses callbacks instead of prompts.
|
||||
|
||||
This method provides the same functionality as configuration_phase() but
|
||||
integrates with GUI frontends using Qt callbacks instead of CLI prompts.
|
||||
|
||||
Args:
|
||||
context: Configuration context dict with modlist details
|
||||
progress_callback: Called with progress messages (str)
|
||||
manual_steps_callback: Called when manual steps needed (modlist_name, retry_count)
|
||||
completion_callback: Called when configuration completes (success, message, modlist_name)
|
||||
"""
|
||||
try:
|
||||
from .modlist_operations import _get_user_proton_version
|
||||
|
||||
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
|
||||
|
||||
try:
|
||||
@@ -38,121 +31,27 @@ class ModlistOperationsConfigurationGUIMixin:
|
||||
'modlist_source': context.get('modlist_source'),
|
||||
'resolution': context.get('resolution'),
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': False
|
||||
}
|
||||
|
||||
existing_app_id = context.get('app_id')
|
||||
if existing_app_id:
|
||||
config_context['appid'] = existing_app_id
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(f"Configuring existing modlist with AppID {existing_app_id}...")
|
||||
if progress_callback:
|
||||
progress_callback("Running modlist configuration...")
|
||||
|
||||
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
|
||||
config_handler = ConfigHandler()
|
||||
modlist_menu = ModlistMenuHandler(config_handler)
|
||||
|
||||
retry_count = 0
|
||||
max_retries = 3
|
||||
|
||||
while retry_count < max_retries:
|
||||
if progress_callback:
|
||||
progress_callback("Running modlist configuration...")
|
||||
|
||||
result = modlist_menu.run_modlist_configuration_phase(config_context)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(f"Configuration attempt {retry_count}: {'Success' if result else 'Failed'}")
|
||||
|
||||
if result:
|
||||
if completion_callback:
|
||||
completion_callback(True, "Core configuration complete", config_context['name'])
|
||||
return True
|
||||
else:
|
||||
retry_count += 1
|
||||
|
||||
if retry_count < max_retries:
|
||||
if progress_callback:
|
||||
progress_callback(f"Configuration failed on attempt {retry_count}, showing manual steps dialog...")
|
||||
if manual_steps_callback:
|
||||
if progress_callback:
|
||||
progress_callback(f"Calling manual_steps_callback for {config_context['name']}, retry {retry_count}")
|
||||
manual_steps_callback(config_context['name'], retry_count)
|
||||
|
||||
config_context['manual_steps_completed'] = True
|
||||
else:
|
||||
if completion_callback:
|
||||
completion_callback(False, "Manual steps failed after multiple attempts", config_context['name'])
|
||||
return False
|
||||
config_handler = ConfigHandler()
|
||||
modlist_menu = ModlistMenuHandler(config_handler)
|
||||
result = modlist_menu.run_modlist_configuration_phase(config_context)
|
||||
|
||||
if result:
|
||||
if completion_callback:
|
||||
completion_callback(False, "Configuration failed", config_context['name'])
|
||||
return False
|
||||
|
||||
completion_callback(True, "Core configuration complete", config_context['name'])
|
||||
return True
|
||||
else:
|
||||
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
|
||||
config_handler = ConfigHandler()
|
||||
modlist_menu = ModlistMenuHandler(config_handler)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("Creating Steam shortcut...")
|
||||
|
||||
from jackify.backend.services.native_steam_service import NativeSteamService
|
||||
steam_service = NativeSteamService()
|
||||
|
||||
proton_version = _get_user_proton_version()
|
||||
|
||||
success, app_id = steam_service.create_shortcut_with_proton(
|
||||
app_name=config_context['name'],
|
||||
exe_path=config_context['mo2_exe_path'],
|
||||
start_dir=os.path.dirname(config_context['mo2_exe_path']),
|
||||
launch_options="%command%",
|
||||
tags=["Jackify"],
|
||||
proton_version=proton_version
|
||||
)
|
||||
|
||||
if not success or not app_id:
|
||||
if completion_callback:
|
||||
completion_callback(False, "Failed to create Steam shortcut", config_context['name'])
|
||||
return False
|
||||
|
||||
config_context['appid'] = app_id
|
||||
|
||||
if progress_callback:
|
||||
from jackify.shared.timing import get_timestamp
|
||||
progress_callback(f"{get_timestamp()} Steam shortcut created successfully")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("Running modlist configuration...")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(f"About to call run_modlist_configuration_phase with context: {config_context}")
|
||||
|
||||
result = modlist_menu.run_modlist_configuration_phase(config_context)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(f"run_modlist_configuration_phase returned: {result}")
|
||||
|
||||
if result:
|
||||
if completion_callback:
|
||||
completion_callback(True, "Core configuration complete", config_context['name'])
|
||||
return True
|
||||
else:
|
||||
if progress_callback:
|
||||
progress_callback("Configuration failed, manual Steam/Proton setup required")
|
||||
if manual_steps_callback:
|
||||
if progress_callback:
|
||||
progress_callback(f"About to call manual_steps_callback for {config_context['name']}, retry 1")
|
||||
manual_steps_callback(config_context['name'], 1)
|
||||
if progress_callback:
|
||||
progress_callback("manual_steps_callback completed")
|
||||
|
||||
return True
|
||||
|
||||
if completion_callback:
|
||||
completion_callback(False, "Configuration failed", config_context['name'])
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
Windows Registry Editor Version 5.00
|
||||
|
||||
[HKEY_CURRENT_USER\Software\Wine\DllOverrides]
|
||||
"d3dcompiler_43"="native"
|
||||
"d3dcompiler_47"="native"
|
||||
"d3dx9_24"="native"
|
||||
"d3dx9_25"="native"
|
||||
"d3dx9_26"="native"
|
||||
"d3dx9_27"="native"
|
||||
"d3dx9_28"="native"
|
||||
"d3dx9_29"="native"
|
||||
"d3dx9_30"="native"
|
||||
"d3dx9_31"="native"
|
||||
"d3dx9_32"="native"
|
||||
"d3dx9_33"="native"
|
||||
"d3dx9_34"="native"
|
||||
"d3dx9_35"="native"
|
||||
"d3dx9_36"="native"
|
||||
"d3dx9_37"="native"
|
||||
"d3dx9_38"="native"
|
||||
"d3dx9_39"="native"
|
||||
"d3dx9_40"="native"
|
||||
"d3dx9_41"="native"
|
||||
"d3dx9_42"="native"
|
||||
"d3dx9_43"="native"
|
||||
"d3dx11_43"="native"
|
||||
"xaudio2_0"="native,builtin"
|
||||
"xaudio2_1"="native,builtin"
|
||||
"xaudio2_2"="native,builtin"
|
||||
"xaudio2_3"="native,builtin"
|
||||
"xaudio2_4"="native,builtin"
|
||||
"xaudio2_5"="native,builtin"
|
||||
"xaudio2_6"="native,builtin"
|
||||
"xaudio2_7"="native,builtin"
|
||||
"x3daudio1_0"="native,builtin"
|
||||
"x3daudio1_1"="native,builtin"
|
||||
"x3daudio1_2"="native,builtin"
|
||||
"x3daudio1_3"="native,builtin"
|
||||
"x3daudio1_4"="native,builtin"
|
||||
"x3daudio1_5"="native,builtin"
|
||||
"x3daudio1_6"="native,builtin"
|
||||
"x3daudio1_7"="native,builtin"
|
||||
"xapofx1_1"="native,builtin"
|
||||
"xapofx1_2"="native,builtin"
|
||||
"xapofx1_3"="native,builtin"
|
||||
"xapofx1_4"="native,builtin"
|
||||
"xapofx1_5"="native,builtin"
|
||||
"xactengine2_0"="native,builtin"
|
||||
"xactengine2_1"="native,builtin"
|
||||
"xactengine2_2"="native,builtin"
|
||||
"xactengine2_3"="native,builtin"
|
||||
"xactengine2_4"="native,builtin"
|
||||
"xactengine2_5"="native,builtin"
|
||||
"xactengine2_6"="native,builtin"
|
||||
"xactengine2_7"="native,builtin"
|
||||
"xactengine2_8"="native,builtin"
|
||||
"xactengine2_9"="native,builtin"
|
||||
"xactengine2_10"="native,builtin"
|
||||
"xactengine3_0"="native,builtin"
|
||||
"xactengine3_1"="native,builtin"
|
||||
"xactengine3_2"="native,builtin"
|
||||
"xactengine3_3"="native,builtin"
|
||||
"xactengine3_4"="native,builtin"
|
||||
"xactengine3_5"="native,builtin"
|
||||
"xactengine3_6"="native,builtin"
|
||||
"xactengine3_7"="native,builtin"
|
||||
"concrt140"="native,builtin"
|
||||
"msvcp140"="native,builtin"
|
||||
"msvcp140_1"="native,builtin"
|
||||
"msvcp140_2"="native,builtin"
|
||||
"msvcp140_atomic_wait"="native,builtin"
|
||||
"msvcp140_codecvt_ids"="native,builtin"
|
||||
"vcamp140"="native,builtin"
|
||||
"vccorlib140"="native,builtin"
|
||||
"vcomp140"="native,builtin"
|
||||
"vcruntime140"="native,builtin"
|
||||
"vcruntime140_1"="native,builtin"
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Per-modlist Proton version requirements for ENB compatibility warnings."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# Keys are lowercase modlist names (matched case-insensitively).
|
||||
# required: specific Proton build known to work
|
||||
# note: shown to the user alongside the requirement
|
||||
MODLIST_PROTON_REQUIREMENTS: dict[str, dict[str, str]] = {
|
||||
"lorerim": {
|
||||
"required": "GE-Proton10-34",
|
||||
"note": "Proton-CachyOS 11 and Valve Proton are known to not work with this list.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_proton_requirement(modlist_name: str) -> Optional[dict[str, str]]:
|
||||
if not modlist_name:
|
||||
return None
|
||||
return MODLIST_PROTON_REQUIREMENTS.get(modlist_name.strip().lower())
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"_comment": "Version-pinned URLs and COM data for native component installer. Update per release.",
|
||||
"dotnet6": {
|
||||
"x86_zip_url": "https://dotnetcli.azureedge.net/dotnet/Runtime/6.0.36/dotnet-runtime-6.0.36-win-x86.zip",
|
||||
"x64_zip_url": "https://dotnetcli.azureedge.net/dotnet/Runtime/6.0.36/dotnet-runtime-6.0.36-win-x64.zip"
|
||||
},
|
||||
"dotnet7": {
|
||||
"x86_zip_url": "https://dotnetcli.azureedge.net/dotnet/Runtime/7.0.20/dotnet-runtime-7.0.20-win-x86.zip",
|
||||
"x64_zip_url": "https://dotnetcli.azureedge.net/dotnet/Runtime/7.0.20/dotnet-runtime-7.0.20-win-x64.zip"
|
||||
},
|
||||
"dotnet8": {
|
||||
"x86_zip_url": "https://dotnetcli.azureedge.net/dotnet/Runtime/8.0.12/dotnet-runtime-8.0.12-win-x86.zip",
|
||||
"x64_zip_url": "https://dotnetcli.azureedge.net/dotnet/Runtime/8.0.12/dotnet-runtime-8.0.12-win-x64.zip"
|
||||
},
|
||||
"dotnet9": {
|
||||
"x86_zip_url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.16/dotnet-runtime-9.0.16-win-x86.zip",
|
||||
"x64_zip_url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.16/dotnet-runtime-9.0.16-win-x64.zip"
|
||||
},
|
||||
"dotnet10": {
|
||||
"x86_zip_url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/10.0.8/dotnet-runtime-10.0.8-win-x86.zip",
|
||||
"x64_zip_url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/10.0.8/dotnet-runtime-10.0.8-win-x64.zip"
|
||||
},
|
||||
"dotnetdesktop6": {
|
||||
"x86_zip_url": "https://dotnetcli.azureedge.net/dotnet/WindowsDesktop/6.0.36/windowsdesktop-runtime-6.0.36-win-x86.zip",
|
||||
"x64_zip_url": "https://dotnetcli.azureedge.net/dotnet/WindowsDesktop/6.0.36/windowsdesktop-runtime-6.0.36-win-x64.zip"
|
||||
},
|
||||
"dotnetdesktop9": {
|
||||
"x86_zip_url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/9.0.16/windowsdesktop-runtime-9.0.16-win-x86.zip",
|
||||
"x64_zip_url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/9.0.16/windowsdesktop-runtime-9.0.16-win-x64.zip"
|
||||
},
|
||||
"dotnetdesktop10": {
|
||||
"x86_zip_url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.8/windowsdesktop-runtime-10.0.8-win-x86.zip",
|
||||
"x64_zip_url": "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.8/windowsdesktop-runtime-10.0.8-win-x64.zip"
|
||||
},
|
||||
"xact_clsids": {
|
||||
"xactengine2_0.dll": ["{0AA000AA-F404-11D9-BD7A-0010DC4F8F81}"],
|
||||
"xactengine2_4.dll": ["{BC3E0FC6-2E0D-4C45-BC61-D9C328319BD8}"],
|
||||
"xactengine2_7.dll": ["{CD0D66EC-8057-43F5-ACBD-66DFB36FD78C}"],
|
||||
"xactengine2_9.dll": ["{343E68E6-8F82-4A8D-A2DA-6E9A944B378C}"],
|
||||
"xactengine3_0.dll": ["{3B80EE2A-B0F5-4780-9E30-90CB39685B03}"],
|
||||
"xactengine3_1.dll": ["{962F5027-99BE-4692-A468-85802CF8DE61}"],
|
||||
"xactengine3_2.dll": ["{D3332F02-3DD0-4DE9-9AEC-20D85C4111B6}"],
|
||||
"xactengine3_3.dll": ["{94C1AFFA-66E7-4961-9521-CFDEF3128D4F}"],
|
||||
"xactengine3_4.dll": ["{0977D092-2D95-4E43-8D42-9DDCC2545ED5}"],
|
||||
"xactengine3_5.dll": ["{074B110F-7F58-4743-AEA5-12F15B5074ED}"],
|
||||
"xactengine3_6.dll": ["{248D8A3B-6256-44D3-A018-2AC96C459F47}"],
|
||||
"xactengine3_7.dll": ["{BCC782BC-6492-4C22-8C35-F5D72FE73C6E}"],
|
||||
"xaudio2_0.dll": ["{FAC23F48-31F5-45A8-B49B-5225D61401AA}", "{6F6EA3A9-2CF5-41CF-91C1-2170B1540063}", "{C0C56F46-29B1-44E9-9939-A32CE86867E2}"],
|
||||
"xaudio2_1.dll": ["{E21A7345-EB21-468E-BE50-804DB97CF708}", "{F4769300-B949-4DF9-B333-00D33932E9A6}", "{C1E3F122-A2EA-442C-854F-20D98F8357A1}"],
|
||||
"xaudio2_2.dll": ["{B802058A-464A-42DB-BC10-B650D6F2586A}", "{629CF0DE-3ECC-41E7-9926-F7E43EEBEC51}", "{F5CA7B34-8055-42C0-B836-216129EB7E30}"],
|
||||
"xaudio2_3.dll": ["{4C5E637A-16C7-4DE3-9C46-5ED22181962D}", "{9CAB402C-1D37-44B4-886D-FA4F36170A4C}", "{E180344B-AC83-4483-959E-18A5C56A5E19}"],
|
||||
"xaudio2_4.dll": ["{03219E78-5BC3-44D1-B92E-F63D89CC6526}", "{8BB7778B-645B-4475-9A73-1DE3170BD3AF}", "{C7338B95-52B8-4542-AA79-42EB016C8C1C}"],
|
||||
"xaudio2_5.dll": ["{4C9B6DDE-6809-46E6-A278-9B6A97588670}", "{D06DF0D0-8518-441E-822F-5451D5C595B8}", "{2139E6DA-C341-4774-9AC3-B4E026347F64}"],
|
||||
"xaudio2_6.dll": ["{3EDA9B49-2085-498B-9BB2-39A6778493DE}", "{CECEC95A-D894-491A-BEE3-5E106FB59F2D}", "{E48C5A3F-93EF-43BB-A092-2C7CEB946F27}"],
|
||||
"xaudio2_7.dll": ["{5A508685-A254-4FBA-9B82-9A24B00306AF}", "{6A93130E-1D53-41D1-A9CF-E758800BB179}", "{CAC1105F-619B-4D04-831A-44E1CBF12D57}"]
|
||||
}
|
||||
}
|
||||
@@ -220,10 +220,21 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
|
||||
def save_config(self):
|
||||
"""Save current configuration to file"""
|
||||
import tempfile
|
||||
try:
|
||||
self._create_config_dir()
|
||||
with open(self.config_file, 'w') as f:
|
||||
json.dump(self.settings, f, indent=2)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(self.config_file), prefix='.config_tmp_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(self.settings, f, indent=2)
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, self.config_file)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
logger.debug("Saved configuration to file")
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -98,13 +98,7 @@ class ConfigEncryptionMixin:
|
||||
else:
|
||||
self.settings["nexus_api_key"] = None
|
||||
logger.debug("API key cleared")
|
||||
result = self.save_config()
|
||||
if result:
|
||||
try:
|
||||
os.chmod(self.config_file, 0o600)
|
||||
except Exception as e:
|
||||
logger.warning("Could not set restrictive permissions on config: %s", e)
|
||||
return result
|
||||
return self.save_config()
|
||||
except Exception as e:
|
||||
logger.error("Error saving API key: %s", e)
|
||||
return False
|
||||
|
||||
@@ -384,31 +384,7 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
|
||||
logger.error(f"Failed to add backupPath entry to {modlist_ini}: {e}")
|
||||
return False # Backup succeeded, but adding entry failed
|
||||
|
||||
def blank_downloads_dir(self, modlist_ini: Path) -> bool:
|
||||
"""
|
||||
Blank or reset the MO2 Downloads Directory
|
||||
Returns True on success, False on failure
|
||||
"""
|
||||
try:
|
||||
self.logger.info("Editing download_directory...")
|
||||
|
||||
# Read the file
|
||||
with open(modlist_ini, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Replace the download_directory line
|
||||
modified_content = re.sub(r'download_directory[^\n]*', 'download_directory =', content)
|
||||
|
||||
# Write back to the file
|
||||
with open(modlist_ini, 'w') as f:
|
||||
f.write(modified_content)
|
||||
|
||||
self.logger.debug("Download directory cleared successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error blanking downloads directory: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def copy_file(self, src: Path, dst: Path, overwrite: bool = False) -> bool:
|
||||
"""
|
||||
|
||||
@@ -38,6 +38,11 @@ class FilesystemOwnershipMixin:
|
||||
Verify and fix ownership/permissions for modlist directory.
|
||||
Returns (success, error_message).
|
||||
"""
|
||||
from jackify.backend.handlers.validation_handler import ValidationHandler
|
||||
if ValidationHandler().is_dangerous_directory(path):
|
||||
logger.error("Refusing to modify permissions on dangerous path: %s", path)
|
||||
return False, f"Refusing to modify permissions on '{path}': system or user root directory."
|
||||
|
||||
if not path.exists():
|
||||
logger.error("Path does not exist: %s", path)
|
||||
return False, f"Path does not exist: {path}"
|
||||
|
||||
@@ -280,8 +280,11 @@ class ModlistMenuHandler:
|
||||
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
|
||||
|
||||
while True:
|
||||
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
|
||||
download_dir = resolve_mo2_download_dir(Path(install_dir))
|
||||
result = prefix_service.run_working_workflow(
|
||||
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
|
||||
modlist_name, install_dir, mo2_path, progress_callback,
|
||||
steamdeck=self.steamdeck, download_dir=download_dir,
|
||||
)
|
||||
|
||||
if isinstance(result, tuple) and len(result) == 4:
|
||||
@@ -303,7 +306,6 @@ class ModlistMenuHandler:
|
||||
"name": modlist_name,
|
||||
"appid": str(existing_appid),
|
||||
"path": mo2_dir,
|
||||
"manual_steps_completed": True,
|
||||
"resolution": None
|
||||
}
|
||||
return self.run_modlist_configuration_phase(context)
|
||||
@@ -335,7 +337,6 @@ class ModlistMenuHandler:
|
||||
"name": modlist_name,
|
||||
"appid": str(appid_int),
|
||||
"path": mo2_dir,
|
||||
"manual_steps_completed": True,
|
||||
"resolution": None
|
||||
}
|
||||
self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}")
|
||||
@@ -534,9 +535,7 @@ class ModlistMenuHandler:
|
||||
else:
|
||||
status_line = f"\r{COLOR_INFO}{msg}{COLOR_RESET}"
|
||||
print(status_line, end="", flush=True)
|
||||
manual_steps_completed = context.get("manual_steps_completed", False)
|
||||
skip_manual_for_existing = context.get("modlist_source") == "existing" # Existing modlists skip manual steps
|
||||
if not self.modlist_handler._execute_configuration_steps(status_callback=update_status, manual_steps_completed=manual_steps_completed, skip_manual_for_existing=skip_manual_for_existing):
|
||||
if not self.modlist_handler._execute_configuration_steps(status_callback=update_status):
|
||||
if status_line:
|
||||
print()
|
||||
self.logger.error(f"Core configuration steps failed for {context.get('name')}")
|
||||
@@ -555,7 +554,6 @@ class ModlistMenuHandler:
|
||||
if not gui_mode:
|
||||
try:
|
||||
from ..handlers.enb_handler import ENBHandler
|
||||
from pathlib import Path
|
||||
|
||||
enb_handler = ENBHandler()
|
||||
install_dir = Path(context.get('path', ''))
|
||||
@@ -587,8 +585,6 @@ class ModlistMenuHandler:
|
||||
create_vnv_cli_progress_callback,
|
||||
ensure_vnv_cli_manual_downloads,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
modlist_name = context.get('name', '')
|
||||
modlist_path = Path(context.get('path', ''))
|
||||
|
||||
@@ -660,37 +656,57 @@ class ModlistMenuHandler:
|
||||
completion_title = "Modlist Configuration complete!" if is_existing_flow else "Modlist Install and Configuration complete!"
|
||||
completion_log_file = "Configure_Existing_Modlist_workflow.log" if is_existing_flow else "Configure_New_Modlist_workflow.log"
|
||||
|
||||
print("")
|
||||
print("")
|
||||
print("") # Extra blank line before completion
|
||||
print("=" * 35)
|
||||
print("= Configuration phase complete =")
|
||||
print("=" * 35)
|
||||
print("")
|
||||
print(completion_title)
|
||||
print(f"• You should now be able to Launch '{context.get('name')}' through Steam")
|
||||
print("• Congratulations and enjoy the game!")
|
||||
print("")
|
||||
if not context.get('suppress_completion_banner'):
|
||||
print("")
|
||||
print("")
|
||||
print("")
|
||||
print("=" * 35)
|
||||
print("= Configuration phase complete =")
|
||||
print("=" * 35)
|
||||
print("")
|
||||
print(completion_title)
|
||||
print(f"• You should now be able to Launch '{context.get('name')}' through Steam")
|
||||
print("• Congratulations and enjoy the game!")
|
||||
print("")
|
||||
|
||||
# Show ENB-specific warning if ENB was detected (replaces generic note)
|
||||
if enb_detected:
|
||||
print(f"{COLOR_WARNING}ENB DETECTED{COLOR_RESET}")
|
||||
print("")
|
||||
print("If you plan on using ENB as part of this modlist, you will need to use")
|
||||
print("one of the following Proton versions, otherwise you will have issues:")
|
||||
print("")
|
||||
print(" (in order of recommendation)")
|
||||
print(f" {COLOR_SUCCESS}• Proton-CachyOS{COLOR_RESET}")
|
||||
print(f" {COLOR_INFO}• GE-Proton 10-14 or lower{COLOR_RESET}")
|
||||
print(f" {COLOR_WARNING}• Proton 9 from Valve{COLOR_RESET}")
|
||||
print("")
|
||||
print(f"{COLOR_WARNING}Note: Valve's Proton 10 has known ENB compatibility issues.{COLOR_RESET}")
|
||||
print("")
|
||||
else:
|
||||
# No ENB detected - no warning needed
|
||||
pass
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
print(f"Detailed log available at: {get_jackify_logs_dir()}/{completion_log_file}")
|
||||
if not context.get('suppress_completion_banner'):
|
||||
if enb_detected:
|
||||
print(f"{COLOR_WARNING}ENB DETECTED{COLOR_RESET}")
|
||||
print("")
|
||||
print("If you plan on using ENB as part of this modlist, you will need to use")
|
||||
print("one of the following Proton versions, otherwise you will have issues:")
|
||||
print("")
|
||||
print(" (in order of recommendation)")
|
||||
print(f" {COLOR_SUCCESS}• Proton-CachyOS{COLOR_RESET}")
|
||||
print(f" {COLOR_INFO}• GE-Proton{COLOR_RESET}")
|
||||
print(f" {COLOR_WARNING}• Proton 9 from Valve{COLOR_RESET}")
|
||||
print("")
|
||||
print(f"{COLOR_WARNING}Note: Valve's Proton 10 has known ENB compatibility issues.{COLOR_RESET}")
|
||||
print("")
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
print(f"Detailed log available at: {get_jackify_logs_dir()}/{completion_log_file}")
|
||||
|
||||
try:
|
||||
install_path = context.get('path')
|
||||
shortcut_name = context.get('name')
|
||||
mo2_exe = context.get('mo2_exe_path') or (
|
||||
os.path.join(install_path, 'ModOrganizer.exe') if install_path else None
|
||||
)
|
||||
if install_path and shortcut_name and mo2_exe:
|
||||
ini_path = Path(install_path) / 'ModOrganizer.ini'
|
||||
dl_str = self.path_handler.get_download_directory_linux_path(ini_path)
|
||||
if dl_str:
|
||||
mounts_result = self.shortcut_handler.ensure_mounts_in_steam_compat(
|
||||
shortcut_name, mo2_exe, dl_str
|
||||
)
|
||||
if mounts_result in ("updated", "steam_running"):
|
||||
context['steam_restart_needed'] = True
|
||||
context['mounts_app_name'] = shortcut_name
|
||||
context['mounts_exe_path'] = mo2_exe
|
||||
context['mounts_dl_path'] = dl_str
|
||||
except Exception as e:
|
||||
self.logger.error("Could not update STEAM_COMPAT_MOUNTS: %s", e, exc_info=True)
|
||||
|
||||
# Only wait for input in CLI mode, not GUI mode
|
||||
if not gui_mode:
|
||||
input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}")
|
||||
|
||||
@@ -48,13 +48,11 @@ class ModlistConfigurationMixin:
|
||||
else:
|
||||
return True
|
||||
|
||||
def _execute_configuration_steps(self, status_callback=None, manual_steps_completed=False, skip_manual_for_existing=False):
|
||||
def _execute_configuration_steps(self, status_callback=None):
|
||||
"""
|
||||
Runs the actual configuration steps for the selected modlist.
|
||||
Args:
|
||||
status_callback (callable, optional): A function to call with status updates during configuration.
|
||||
manual_steps_completed (bool): If True, skip the manual steps prompt (used for new modlist flow).
|
||||
skip_manual_for_existing (bool): If True, always skip manual steps (for existing modlists that are already configured).
|
||||
"""
|
||||
try:
|
||||
# Store status_callback for Configuration Summary
|
||||
@@ -88,64 +86,6 @@ class ModlistConfigurationMixin:
|
||||
return False # Abort on failure
|
||||
self.logger.info("Step 1: Setting Protontricks permissions... Done")
|
||||
|
||||
# Step 2: Prompt user for manual steps and wait for compatdata
|
||||
skip_manual_prompt = skip_manual_for_existing # Existing modlists skip manual steps
|
||||
if not manual_steps_completed and not skip_manual_for_existing:
|
||||
# Check if Proton Experimental is already set and compatdata exists
|
||||
proton_ok = False
|
||||
compatdata_ok = False
|
||||
|
||||
# Check Proton version
|
||||
self.logger.debug(f"[MANUAL STEPS DEBUG] Checking Proton version for AppID {self.appid}")
|
||||
if self._detect_proton_version():
|
||||
self.logger.debug(f"[MANUAL STEPS DEBUG] Detected Proton version: {self.proton_ver}")
|
||||
if self.proton_ver and 'experimental' in self.proton_ver.lower():
|
||||
proton_ok = True
|
||||
self.logger.debug("[MANUAL STEPS DEBUG] Proton Experimental detected - proton_ok = True")
|
||||
else:
|
||||
self.logger.debug("[MANUAL STEPS DEBUG] Could not detect Proton version")
|
||||
|
||||
# Check compatdata/prefix
|
||||
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
|
||||
self.logger.debug(f"[MANUAL STEPS DEBUG] Compatdata path search result: {prefix_path_str}")
|
||||
|
||||
if prefix_path_str and os.path.isdir(prefix_path_str):
|
||||
compatdata_ok = True
|
||||
self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory exists - compatdata_ok = True")
|
||||
else:
|
||||
self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory does not exist")
|
||||
|
||||
self.logger.debug(f"[MANUAL STEPS DEBUG] proton_ok: {proton_ok}, compatdata_ok: {compatdata_ok}")
|
||||
|
||||
if proton_ok and compatdata_ok:
|
||||
self.logger.info("Proton Experimental and compatdata already set for this AppID; skipping manual steps prompt.")
|
||||
skip_manual_prompt = True
|
||||
else:
|
||||
self.logger.debug("[MANUAL STEPS DEBUG] Manual steps will be required")
|
||||
|
||||
self.logger.debug(f"[MANUAL STEPS DEBUG] manual_steps_completed: {manual_steps_completed}, skip_manual_prompt: {skip_manual_prompt}")
|
||||
|
||||
if not manual_steps_completed and not skip_manual_prompt:
|
||||
# Check if we're in GUI mode - if so, don't show CLI prompts, just fail and let GUI callbacks handle it
|
||||
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
|
||||
|
||||
if gui_mode:
|
||||
# In GUI mode: don't show CLI prompts, just fail so GUI can show dialog and retry
|
||||
self.logger.info("GUI mode detected: skipping CLI manual steps prompt, will fail configuration to trigger GUI callback")
|
||||
if status_callback:
|
||||
status_callback("Manual Steam/Proton setup required - this will be handled by GUI dialog")
|
||||
# Return False to trigger manual steps callback in GUI
|
||||
return False
|
||||
else:
|
||||
# CLI mode: show the traditional CLI prompt
|
||||
if status_callback:
|
||||
status_callback("Please perform the manual steps in Steam (set Proton, launch shortcut, then close MO2)...")
|
||||
self.logger.info("Prompting user to perform manual Steam/Proton steps and launch shortcut.")
|
||||
print("\n───────────────────────────────────────────────────────────────────")
|
||||
print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} Please follow the on-screen instructions to set Proton Experimental and launch the shortcut from Steam.")
|
||||
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: Apply targeted registry tweaks (replaces wholesale curated reg file overwrite)
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Applying modlist registry configuration")
|
||||
@@ -162,7 +102,31 @@ class ModlistConfigurationMixin:
|
||||
|
||||
# Use canonical logic for all modlists/games
|
||||
components = self.get_modlist_wine_components(self.game_name, self.game_var_full)
|
||||
|
||||
|
||||
# NSF detection: dotnet48 required for Skyrim SE modlists with NetScriptFramework
|
||||
if 'skyrim' in (self.game_var_full or '').lower() and self.modlist_dir:
|
||||
nsf_markers = [
|
||||
Path(self.modlist_dir) / 'mods' / 'NetScriptFramework' / 'DLLPlugins' / 'NetScriptFramework.Runtime.dll',
|
||||
Path(self.modlist_dir) / 'mods' / 'NetScriptFramework' / 'Plugins' / 'CustomSkills.dll',
|
||||
Path(self.modlist_dir) / 'mods' / 'NetScriptFramework',
|
||||
]
|
||||
if any(m.exists() for m in nsf_markers):
|
||||
self._nsf_detected = True
|
||||
if 'dotnet48' not in components:
|
||||
self.logger.info("NetScriptFramework detected, adding dotnet48 to component list")
|
||||
components.insert(0, 'dotnet48')
|
||||
|
||||
# Developer/testing override: JACKIFY_SKIP_WINE_COMPONENTS=comp1,comp2 drops those
|
||||
# components from this run, for iterating without re-installing slow verbs (e.g.
|
||||
# dotnetdesktop6). Unset in normal use, so it has no effect on real installs.
|
||||
_skip_env = os.environ.get('JACKIFY_SKIP_WINE_COMPONENTS', '').strip()
|
||||
if _skip_env:
|
||||
_skip_set = {c.strip() for c in _skip_env.split(',') if c.strip()}
|
||||
_removed = [c for c in components if c in _skip_set]
|
||||
if _removed:
|
||||
components = [c for c in components if c not in _skip_set]
|
||||
self.logger.warning("JACKIFY_SKIP_WINE_COMPONENTS active - skipping %s (testing override)", _removed)
|
||||
|
||||
# All modlists now use their own AppID for wine components
|
||||
target_appid = self.appid
|
||||
|
||||
@@ -414,7 +378,8 @@ class ModlistConfigurationMixin:
|
||||
modlist_ini_path=modlist_ini_path_obj,
|
||||
modlist_dir_path=modlist_dir_path_obj,
|
||||
modlist_sdcard=self.modlist_sdcard,
|
||||
steam_libraries=steam_libraries
|
||||
steam_libraries=steam_libraries,
|
||||
compat_data_path=getattr(self, 'compat_data_path', None)
|
||||
):
|
||||
self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini. Configuration aborted.")
|
||||
self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini.")
|
||||
@@ -447,7 +412,7 @@ class ModlistConfigurationMixin:
|
||||
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
|
||||
# Kept separate from broad binary-path rewriting so it still runs when
|
||||
# engine-installed workflows skip edit_binary_working_paths.
|
||||
if not self.path_handler.align_home_path_basis(
|
||||
modlist_ini_path=modlist_ini_path_obj,
|
||||
@@ -625,12 +590,21 @@ class ModlistConfigurationMixin:
|
||||
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
|
||||
# NSF/CSF modlists need the global *mscoree=native that winetricks dotnet48
|
||||
# set: the per-exe scoping this function applies starves NSF's CLR hosting
|
||||
# (verified directly). So preserve the global override for them.
|
||||
# dotnet9 SDK install also flips the prefix to win11; NSF/CSF was only
|
||||
# verified working on win10 + global-native, so we keep that state and skip
|
||||
# the dotnet9/win11 step. Whether Synthesis runs under global-native on an NSF
|
||||
# prefix is untested - revisit if a modlist needs live Synthesis.
|
||||
_nsf = getattr(self, '_nsf_detected', False)
|
||||
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_dotnet9_sdk=not _nsf,
|
||||
install_fxc2_d3dcompiler=True,
|
||||
preserve_global_mscoree=_nsf,
|
||||
)
|
||||
self.logger.info("Step 15: Tool compatibility settings applied")
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Modlist fixup handler.
|
||||
|
||||
Applies known binary fixes to installed modlists during the configure phase.
|
||||
Each fix is idempotent: it checks the current state before acting and skips
|
||||
if the fix is already in place.
|
||||
"""
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# JContainers SE - Linux crash fix
|
||||
# Nexus releases up to and including 4.2.9.0 crash on Linux.
|
||||
# Fixed build from https://github.com/rfortier/JContainers-rwf (pending Nexus release).
|
||||
# We replace the DLL in-place in whichever mod directory ships it.
|
||||
_JCONTAINERS_FIXED_SHA256 = "4c00d7194c61097361e8f93521d79752a975fca5f54446e391debb0c56999590"
|
||||
_JCONTAINERS_FIXED_URL = (
|
||||
"https://github.com/rfortier/JContainers-rwf/releases/download/v4.2.13.2/"
|
||||
"JContainers64-v4.2.13.2.for.1.6.1170.patch.luajit.with.gc64.7z"
|
||||
)
|
||||
_JCONTAINERS_DLL_NAME = "JContainers64.dll"
|
||||
_JCONTAINERS_ARCHIVE_NAME = "JContainers64-v4.2.13.2.linux-fix.7z"
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _get_7z() -> Optional[Path]:
|
||||
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 _find_dll_in_archive(seven_z: Path, archive: Path) -> Optional[str]:
|
||||
"""Return the internal path of JContainers64.dll within the archive, or None."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(seven_z), "l", "-ba", "-slt", str(archive)],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("Path = ") and line.lower().endswith(_JCONTAINERS_DLL_NAME.lower()):
|
||||
return line[7:].strip()
|
||||
except Exception as exc:
|
||||
logger.debug("7z list failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
_JCONTAINERS_TARGET_SKSE = "skse64_1_6_1170.dll"
|
||||
|
||||
|
||||
def _detect_skse_dll(mods_dir: Path) -> Optional[str]:
|
||||
"""Return the SKSE loader DLL filename found in mods, or None."""
|
||||
for dll in mods_dir.glob("*/Root/skse64_*.dll"):
|
||||
return dll.name
|
||||
return None
|
||||
|
||||
|
||||
def check_jcontainers_needs_fix(modlist_dir: Path, game_type: Optional[str]) -> list:
|
||||
"""Return list of JContainers64.dll paths that need replacing, or empty list."""
|
||||
if not game_type or "skyrim" not in game_type.lower():
|
||||
return []
|
||||
mods_dir = Path(modlist_dir) / "mods"
|
||||
if not mods_dir.is_dir():
|
||||
return []
|
||||
if _detect_skse_dll(mods_dir) != _JCONTAINERS_TARGET_SKSE:
|
||||
return []
|
||||
targets = list(mods_dir.glob(f"*/SKSE/Plugins/{_JCONTAINERS_DLL_NAME}"))
|
||||
return [p for p in targets if _sha256(p) != _JCONTAINERS_FIXED_SHA256]
|
||||
|
||||
|
||||
def apply_jcontainers_fix(
|
||||
modlist_dir: Path,
|
||||
game_var_full: Optional[str],
|
||||
status_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Replace a broken JContainers64.dll with the Linux-compatible build.
|
||||
|
||||
Scoped to Skyrim-based modlists only. Skips silently if already fixed,
|
||||
if no JContainers mod is found, or if required tools are unavailable.
|
||||
"""
|
||||
if not game_var_full or "skyrim" not in game_var_full.lower():
|
||||
return
|
||||
|
||||
mods_dir = Path(modlist_dir) / "mods"
|
||||
if not mods_dir.is_dir():
|
||||
return
|
||||
|
||||
if _detect_skse_dll(mods_dir) != _JCONTAINERS_TARGET_SKSE:
|
||||
return
|
||||
|
||||
targets = list(mods_dir.glob(f"*/SKSE/Plugins/{_JCONTAINERS_DLL_NAME}"))
|
||||
if not targets:
|
||||
logger.debug("JContainers fix: no %s found under %s", _JCONTAINERS_DLL_NAME, mods_dir)
|
||||
return
|
||||
|
||||
needs_fix = [p for p in targets if _sha256(p) != _JCONTAINERS_FIXED_SHA256]
|
||||
if not needs_fix:
|
||||
logger.debug("JContainers fix: all instances already at known-good version")
|
||||
return
|
||||
|
||||
seven_z = _get_7z()
|
||||
if not seven_z:
|
||||
logger.warning("JContainers fix: 7z not available, cannot apply fix")
|
||||
return
|
||||
|
||||
if status_callback:
|
||||
status_callback("Applying JContainers Linux compatibility fix")
|
||||
logger.info("JContainers fix: %d instance(s) need replacement", len(needs_fix))
|
||||
|
||||
cache_dir = get_jackify_data_dir() / "component_cache" / "fixups"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = cache_dir / _JCONTAINERS_ARCHIVE_NAME
|
||||
|
||||
if not archive_path.exists():
|
||||
logger.info("JContainers fix: downloading fixed archive from %s", _JCONTAINERS_FIXED_URL)
|
||||
try:
|
||||
urllib.request.urlretrieve(_JCONTAINERS_FIXED_URL, archive_path)
|
||||
except Exception as exc:
|
||||
logger.error("JContainers fix: download failed: %s", exc)
|
||||
return
|
||||
|
||||
dll_internal_path = _find_dll_in_archive(seven_z, archive_path)
|
||||
if not dll_internal_path:
|
||||
logger.error("JContainers fix: could not locate %s inside archive", _JCONTAINERS_DLL_NAME)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(seven_z), "e", str(archive_path), f"-o{tmp}", dll_internal_path, "-y"],
|
||||
capture_output=True, timeout=60, check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.error("JContainers fix: extraction failed: %s", exc)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
extracted = Path(tmp) / _JCONTAINERS_DLL_NAME
|
||||
if not extracted.exists():
|
||||
logger.error("JContainers fix: %s not found after extraction", _JCONTAINERS_DLL_NAME)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
actual_hash = _sha256(extracted)
|
||||
if actual_hash != _JCONTAINERS_FIXED_SHA256:
|
||||
logger.error(
|
||||
"JContainers fix: extracted DLL hash %s does not match expected %s, aborting",
|
||||
actual_hash, _JCONTAINERS_FIXED_SHA256,
|
||||
)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
import shutil
|
||||
for target in needs_fix:
|
||||
try:
|
||||
backup = target.with_suffix(".dll.bak")
|
||||
shutil.copy2(str(target), str(backup))
|
||||
logger.info("JContainers fix: backed up %s -> %s", target.name, backup.name)
|
||||
shutil.copy2(str(extracted), str(target))
|
||||
logger.info("JContainers fix: replaced %s", target)
|
||||
except Exception as exc:
|
||||
logger.error("JContainers fix: failed to replace %s: %s", target, exc)
|
||||
@@ -485,8 +485,11 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
print(f"{COLOR_INFO}{message}{COLOR_RESET}")
|
||||
|
||||
try:
|
||||
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
|
||||
_download_dir = resolve_mo2_download_dir(Path(install_dir_str))
|
||||
_result = prefix_service.run_working_workflow(
|
||||
shortcut_name, install_dir_str, mo2_exe_path, _cli_progress, steamdeck=self.steamdeck
|
||||
shortcut_name, install_dir_str, mo2_exe_path, _cli_progress,
|
||||
steamdeck=self.steamdeck, download_dir=_download_dir,
|
||||
)
|
||||
except Exception as _wf_err:
|
||||
from jackify.shared.errors import JackifyError
|
||||
@@ -517,7 +520,6 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
'mo2_exe_path': mo2_exe_path,
|
||||
'resolution': self.context.get('resolution'),
|
||||
'skip_confirmation': is_gui_mode,
|
||||
'manual_steps_completed': True
|
||||
}
|
||||
|
||||
from .menu_handler import ModlistMenuHandler
|
||||
|
||||
@@ -111,34 +111,10 @@ class ModlistInstallCLINexusMixin:
|
||||
return []
|
||||
|
||||
def _enhance_nexus_error(self, line: str) -> str:
|
||||
"""
|
||||
Enhance Nexus download error messages by adding the mod URL for easier troubleshooting.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Pattern to match Nexus download errors with ModID and FileID
|
||||
nexus_error_pattern = r"Failed to download '[^']+' from Nexus \(Game: ([^,]+), ModID: (\d+), FileID: \d+\):"
|
||||
|
||||
match = re.search(nexus_error_pattern, line)
|
||||
if match:
|
||||
game_name = match.group(1)
|
||||
mod_id = match.group(2)
|
||||
|
||||
# Map game names to Nexus URL segments
|
||||
game_url_map = {
|
||||
'SkyrimSpecialEdition': 'skyrimspecialedition',
|
||||
'Skyrim': 'skyrim',
|
||||
'Fallout4': 'fallout4',
|
||||
'FalloutNewVegas': 'newvegas',
|
||||
'Oblivion': 'oblivion',
|
||||
'Starfield': 'starfield'
|
||||
}
|
||||
|
||||
game_url = game_url_map.get(game_name, game_name.lower())
|
||||
mod_url = f"https://www.nexusmods.com/{game_url}/mods/{mod_id}"
|
||||
|
||||
# Add URL on next line for easier debugging
|
||||
return f"{line}\n Nexus URL: {mod_url}"
|
||||
|
||||
"""Enhance Nexus download error messages by adding the mod URL for easier troubleshooting."""
|
||||
from jackify.backend.utils.engine_error_parser import nexus_url_from_error_line
|
||||
url = nexus_url_from_error_line(line)
|
||||
if url:
|
||||
return f"{line}\n Nexus URL: {url}"
|
||||
return line
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ class ModlistInstallCLITTWMixin:
|
||||
print(f"\n{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}TTW Integration Available{COLOR_RESET}")
|
||||
print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
|
||||
print(f"\nThis modlist ({modlist_name}) supports Tale of Two Wastelands (TTW).")
|
||||
print(f"\nThis modlist ({modlist_name}) requires Tale of Two Wastelands (TTW).")
|
||||
print(f"TTW combines Fallout 3 and New Vegas into a single game.")
|
||||
print(f"\nWould you like to install TTW now?")
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Wine/Proton operation methods for ModlistHandler (Mixin)."""
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional, List
|
||||
from typing import Tuple, Optional
|
||||
import os
|
||||
import logging
|
||||
import subprocess
|
||||
import shutil
|
||||
import time
|
||||
import vdf
|
||||
import json
|
||||
import configparser
|
||||
@@ -64,11 +63,11 @@ class ModlistWineOpsMixin:
|
||||
with open(str(config_vdf_path), 'r') as f:
|
||||
config_data = vdf.load(f, mapper=vdf.VDFDict)
|
||||
|
||||
# Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name
|
||||
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, {})
|
||||
proton_tool_name = app_mapping.get('name') # CORRECTED: Use lowercase 'name'
|
||||
self.proton_ver = proton_tool_name # Store detected version
|
||||
proton_tool_name = app_mapping.get('name')
|
||||
self.proton_ver = proton_tool_name
|
||||
|
||||
if proton_tool_name:
|
||||
self.logger.info(f"Proton tool name from config.vdf: {proton_tool_name}")
|
||||
@@ -156,19 +155,19 @@ class ModlistWineOpsMixin:
|
||||
self.logger.error(f"Steam userdata directory not found at {userdata_base}")
|
||||
return
|
||||
|
||||
images = [
|
||||
("grid-hero.png", f"{appid}_hero.png"),
|
||||
("grid-logo.png", f"{appid}_logo.png"),
|
||||
("grid-tall.png", f"{appid}p.png"),
|
||||
("grid-wide.png", f"{appid}.png"),
|
||||
]
|
||||
|
||||
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-hero.png", f"{appid}_hero.png"),
|
||||
("grid-logo.png", f"{appid}_logo.png"),
|
||||
("grid-tall.png", f"{appid}p.png"),
|
||||
("grid-wide.png", f"{appid}.png"),
|
||||
]
|
||||
|
||||
for src_name, dest_name in images:
|
||||
src_path = steam_icons_dir / src_name
|
||||
dest_path = grid_dir / dest_name
|
||||
@@ -193,19 +192,17 @@ class ModlistWineOpsMixin:
|
||||
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}")
|
||||
shutil.copyfile(wide_src, tenfoot_dest)
|
||||
self.logger.info(f"Copied landscape image as tenfoot fallback: {tenfoot_dest}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not generate tenfoot image: {e}")
|
||||
self.logger.warning(f"Could not copy 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."""
|
||||
missing_sources = [src for src, _ in images if not (steam_icons_dir / src).exists()]
|
||||
if missing_sources:
|
||||
self._try_steamgriddb_artwork(appid, game_type, modlist_dir, skip_existing=True)
|
||||
|
||||
def _try_steamgriddb_artwork(self, appid: str, game_type: str = None, modlist_dir: str = None, skip_existing: bool = False):
|
||||
"""Fetch artwork from SteamGridDB. When skip_existing is True, slots already present in grid_dir are not overwritten."""
|
||||
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)
|
||||
@@ -241,19 +238,23 @@ class ModlistWineOpsMixin:
|
||||
for src_name, dest_name in images:
|
||||
src = tmp_dir / src_name
|
||||
if src.exists():
|
||||
dest = grid_dir / dest_name
|
||||
if skip_existing and dest.exists():
|
||||
continue
|
||||
try:
|
||||
shutil.copyfile(src, grid_dir / dest_name)
|
||||
shutil.copyfile(src, dest)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to copy {src_name}: {e}")
|
||||
|
||||
# Generate tenfoot from landscape
|
||||
tenfoot_dest = grid_dir / f"{appid}_tenfoot.png"
|
||||
wide = tmp_dir / "grid-wide.png"
|
||||
if wide.exists():
|
||||
if wide.exists() and not (skip_existing and tenfoot_dest.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"))
|
||||
img.scaled(600, 350).save(str(tenfoot_dest))
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not generate tenfoot: {e}")
|
||||
|
||||
@@ -271,7 +272,9 @@ class ModlistWineOpsMixin:
|
||||
# Determine game type
|
||||
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:
|
||||
if "fallout4vr" in game or "fo4vr" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "vcrun2012"]
|
||||
elif "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", "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"]
|
||||
@@ -287,10 +290,13 @@ class ModlistWineOpsMixin:
|
||||
for key, components in self.MODLIST_WINE_COMPONENTS.items():
|
||||
if key in modlist_lower:
|
||||
extras += components
|
||||
# Remove duplicates while preserving order
|
||||
# Remove duplicates while preserving order, then promote Wine EXE installers
|
||||
# (dotnet48, dotnet40) to the front so the long-running installs happen first.
|
||||
seen = set()
|
||||
full_list = [x for x in default_components + extras if not (x in seen or seen.add(x))]
|
||||
return full_list
|
||||
_slow = [c for c in full_list if c in ('dotnet48', 'dotnet40')]
|
||||
_rest = [c for c in full_list if c not in ('dotnet48', 'dotnet40')]
|
||||
return _slow + _rest
|
||||
|
||||
def _re_enforce_windows_10_mode(self):
|
||||
"""
|
||||
@@ -499,6 +505,9 @@ class ModlistWineOpsMixin:
|
||||
else:
|
||||
self.logger.error(f"Failed to set OnlyUseLatestCLR: returncode={result2.returncode}, stderr={result2.stderr}")
|
||||
|
||||
# NDP v4.8 keys (Release DWORD etc.) are written by the winetricks dotnet48 verb
|
||||
# during component installation; they are not set here.
|
||||
|
||||
# Force wineserver to flush registry changes to disk
|
||||
if wineserver_binary:
|
||||
self.logger.debug("Flushing registry changes to disk via wineserver shutdown...")
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
"""Native Wine component installer.
|
||||
|
||||
Direct-source replacements for winetricks components.
|
||||
Falls back to winetricks -> protontricks for unsupported or failed components.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_D3DCOMPILER_47_X86_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompiler_47_32.dll"
|
||||
_D3DCOMPILER_47_X64_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompiler_47.dll"
|
||||
_D3DCOMPILER_47_X86_SHA256 = "2ad0d4987fc4624566b190e747c9d95038443956ed816abfd1e2d389b5ec0851"
|
||||
_D3DCOMPILER_47_X64_SHA256 = "4432bbd1a390874f3f0a503d45cc48d346abc3a8c0213c289f4b615bf0ee84f3"
|
||||
|
||||
_DIRECTX_CAB_URL = "https://files.holarse-linuxgaming.de/mirrors/microsoft/directx_Jun2010_redist.exe"
|
||||
_DIRECTX_CAB_SHA256 = "8746ee1a84a083a90e37899d71d50d5c7c015e69688a466aa80447f011780c0d"
|
||||
|
||||
_VCRUN2022_X86_URL = "https://aka.ms/vs/17/release/vc_redist.x86.exe"
|
||||
_VCRUN2022_X64_URL = "https://aka.ms/vs/17/release/vc_redist.x64.exe"
|
||||
|
||||
_VCRUN2022_DLLS_X86 = ["concrt140.dll", "msvcp140.dll", "msvcp140_1.dll", "msvcp140_2.dll",
|
||||
"msvcp140_atomic_wait.dll", "msvcp140_codecvt_ids.dll",
|
||||
"vcamp140.dll", "vccorlib140.dll", "vcomp140.dll", "vcruntime140.dll"]
|
||||
_VCRUN2022_DLLS_X64 = _VCRUN2022_DLLS_X86 + ["vcruntime140_1.dll"]
|
||||
|
||||
_VCRUN2012_X86_URL = "https://download.microsoft.com/download/1/6/B/16B06F60-3B20-4FF2-B699-5E9B7962F9AE/VSU_4/vcredist_x86.exe"
|
||||
_VCRUN2012_X64_URL = "https://download.microsoft.com/download/1/6/B/16B06F60-3B20-4FF2-B699-5E9B7962F9AE/VSU_4/vcredist_x64.exe"
|
||||
|
||||
|
||||
# (x86_stage1_patterns, x64_stage1_patterns, x86_dll_filters, x64_dll_filters, regsvr32)
|
||||
_DX_CFG: Dict[str, tuple] = {
|
||||
"d3dcompiler_43": (
|
||||
["*d3dcompiler_43*x86*"], ["*d3dcompiler_43*x64*"],
|
||||
["d3dcompiler_43.dll"], ["d3dcompiler_43.dll"], False),
|
||||
"d3dx9": (
|
||||
["*d3dx9*x86*"], ["*d3dx9*x64*"],
|
||||
["d3dx9_*.dll"], ["d3dx9_*.dll"], False),
|
||||
"d3dx9_43": (
|
||||
["*d3dx9*x86*"], ["*d3dx9*x64*"],
|
||||
["d3dx9_43.dll"], ["d3dx9_43.dll"], False),
|
||||
"d3dx11_43": (
|
||||
["*d3dx11_43*x86*"], ["*d3dx11_43*x64*"],
|
||||
["d3dx11_43.dll"], ["d3dx11_43.dll"], False),
|
||||
"xact": (
|
||||
["*_xact_*x86*", "*_x3daudio_*x86*", "*_xaudio_*x86*"], [],
|
||||
["xactengine*.dll", "xaudio*.dll", "x3daudio*.dll", "xapofx*.dll"], [], True),
|
||||
"xact_x64": (
|
||||
[], ["*_xact_*x64*", "*_x3daudio_*x64*", "*_xaudio_*x64*"],
|
||||
[], ["xactengine*.dll", "xaudio*.dll", "x3daudio*.dll", "xapofx*.dll"], True),
|
||||
}
|
||||
|
||||
_REGISTRY_WRITE_COMPONENTS = {"fontsmooth=rgb"}
|
||||
_DLL_COPY_COMPONENTS = {"d3dcompiler_47"}
|
||||
_DIRECTX_CAB_COMPONENTS = set(_DX_CFG.keys())
|
||||
_WINE_INSTALLER_COMPONENTS = {"vcrun2022", "vcrun2012", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "dotnet9", "dotnetdesktop9", "dotnet10", "dotnetdesktop10"}
|
||||
|
||||
# dotnet48 is handled by the bundled winetricks verb (see winetricks_handler), not natively:
|
||||
# the ndp48 in-place servicing corrupts mscorlib under Wine.
|
||||
SUPPORTED_COMPONENTS = (
|
||||
_REGISTRY_WRITE_COMPONENTS
|
||||
| _DLL_COPY_COMPONENTS
|
||||
| _DIRECTX_CAB_COMPONENTS
|
||||
| _WINE_INSTALLER_COMPONENTS
|
||||
)
|
||||
|
||||
|
||||
class NativeComponentInstaller:
|
||||
"""Direct-source Wine component installer. Handles Groups 1-4 from the native install spec."""
|
||||
|
||||
def __init__(self, wineprefix: str, wine_binary: str, wine_env: dict, log=None):
|
||||
self.wineprefix = wineprefix
|
||||
self.wine_binary = wine_binary
|
||||
self.wine_env = wine_env
|
||||
self.logger = log or logging.getLogger(__name__)
|
||||
|
||||
def _emit_status(self, msg: str) -> None:
|
||||
cb = getattr(self, '_status_callback', None)
|
||||
if cb:
|
||||
cb(msg)
|
||||
|
||||
def _wine_env_base(self, **extra) -> dict:
|
||||
base = {**self.wine_env, 'WINEPREFIX': self.wineprefix}
|
||||
parts = []
|
||||
for src in (base, extra):
|
||||
v = src.pop('WINEDLLOVERRIDES', None)
|
||||
if v:
|
||||
parts.append(v)
|
||||
parts.append('winemenubuilder.exe=d')
|
||||
base.update(extra)
|
||||
base['WINEDLLOVERRIDES'] = ','.join(parts)
|
||||
return base
|
||||
|
||||
def install_components(
|
||||
self,
|
||||
components: List[str],
|
||||
status_callback=None,
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Attempt native install. Returns (succeeded, remaining); remaining goes to winetricks."""
|
||||
self._status_callback = status_callback
|
||||
succeeded = []
|
||||
remaining = []
|
||||
|
||||
native_candidates = [c for c in components if c in SUPPORTED_COMPONENTS]
|
||||
|
||||
for component in components:
|
||||
if component not in SUPPORTED_COMPONENTS:
|
||||
remaining.append(component)
|
||||
continue
|
||||
|
||||
self._current_component = component
|
||||
if status_callback:
|
||||
status_callback(f"[NATIVE_INSTALL] {component}")
|
||||
|
||||
try:
|
||||
ok = self._install_component(component)
|
||||
except Exception as exc:
|
||||
self.logger.error("Native install of %s raised: %s", component, exc, exc_info=True)
|
||||
ok = False
|
||||
|
||||
if ok:
|
||||
self.logger.info("Native install succeeded: %s", component)
|
||||
succeeded.append(component)
|
||||
self._write_winetricks_log(component)
|
||||
else:
|
||||
self.logger.warning("Native install failed for %s, falling through to winetricks", component)
|
||||
remaining.append(component)
|
||||
|
||||
if succeeded:
|
||||
self._apply_dll_overrides()
|
||||
|
||||
return succeeded, remaining
|
||||
|
||||
def _install_component(self, component: str) -> bool:
|
||||
if component in _REGISTRY_WRITE_COMPONENTS:
|
||||
return self._install_registry_write(component)
|
||||
if component in _DLL_COPY_COMPONENTS:
|
||||
return self._install_dll_copy(component)
|
||||
if component in _DIRECTX_CAB_COMPONENTS:
|
||||
return self._install_directx_cab(component)
|
||||
if component == "vcrun2022":
|
||||
return self._install_vcrun2022()
|
||||
if component == "vcrun2012":
|
||||
return self._install_vcrun2012()
|
||||
return self._install_dotnet_modern(component)
|
||||
|
||||
def _write_winetricks_log(self, component: str) -> None:
|
||||
log_path = Path(self.wineprefix) / 'winetricks.log'
|
||||
try:
|
||||
with open(log_path, 'a', encoding='utf-8') as f:
|
||||
f.write(component + '\n')
|
||||
except Exception as exc:
|
||||
self.logger.warning("Could not write winetricks.log: %s", exc)
|
||||
self._write_jackify_component_record(component)
|
||||
|
||||
def _write_jackify_component_record(self, component: str) -> None:
|
||||
record_path = Path(self.wineprefix) / 'jackify_components.json'
|
||||
try:
|
||||
record = json.loads(record_path.read_text(encoding='utf-8')) if record_path.is_file() else {}
|
||||
except Exception:
|
||||
record = {}
|
||||
record[component] = {"method": "native", "timestamp": datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S')}
|
||||
try:
|
||||
record_path.write_text(json.dumps(record, indent=2), encoding='utf-8')
|
||||
except Exception as exc:
|
||||
self.logger.warning("Could not write jackify_components.json: %s", exc)
|
||||
|
||||
def _apply_dll_overrides(self) -> None:
|
||||
reg_file = Path(__file__).parent.parent / 'data' / 'dll_overrides.reg'
|
||||
if not reg_file.is_file():
|
||||
self.logger.warning("dll_overrides.reg not found at %s", reg_file)
|
||||
return
|
||||
overrides: Dict[str, str] = {}
|
||||
in_target = False
|
||||
for line in reg_file.read_text(encoding='utf-8').splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith('['):
|
||||
in_target = 'Wine\\DllOverrides' in s
|
||||
elif in_target and s.startswith('"'):
|
||||
try:
|
||||
q = s.index('"', 1)
|
||||
rest = s[q + 1:]
|
||||
if rest.startswith('='):
|
||||
overrides[s[1:q]] = rest[1:]
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
if overrides:
|
||||
self._direct_reg_write(r'Software\Wine\DllOverrides', overrides)
|
||||
self.logger.debug("DLL overrides written directly to user.reg (%d entries)", len(overrides))
|
||||
|
||||
def _download_file(self, url: str, dest: Path, sha256: str = "") -> bool:
|
||||
if dest.is_file():
|
||||
if not sha256:
|
||||
return True
|
||||
if self._verify_sha256(dest, sha256):
|
||||
return True
|
||||
self.logger.warning("SHA256 mismatch on cached %s, re-downloading", dest.name)
|
||||
dest.unlink()
|
||||
component = getattr(self, '_current_component', dest.stem)
|
||||
self.logger.info("Downloading %s ...", dest.name)
|
||||
self._emit_status(f"Downloading {dest.name}...")
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Jackify/1.0'})
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
total = int(resp.headers.get('Content-Length', 0) or 0)
|
||||
downloaded = 0
|
||||
start = time.monotonic()
|
||||
last_emit = start
|
||||
chunk_size = 65536
|
||||
with open(dest, 'wb') as f:
|
||||
while True:
|
||||
chunk = resp.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
now = time.monotonic()
|
||||
if total > 0 and now - last_emit >= 0.5:
|
||||
pct = downloaded / total * 100.0
|
||||
elapsed = now - start
|
||||
speed = downloaded / elapsed / 1048576.0 if elapsed > 0.05 else 0.0
|
||||
self._emit_status(f"[NATIVE_DL] {component} {pct:.1f} {speed:.1f}")
|
||||
last_emit = now
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.logger.error("Download failed for %s: %s", url, exc)
|
||||
if dest.is_file():
|
||||
dest.unlink()
|
||||
return False
|
||||
|
||||
def _verify_sha256(self, path: Path, expected: str) -> bool:
|
||||
h = hashlib.sha256()
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(65536), b''):
|
||||
h.update(chunk)
|
||||
return h.hexdigest().lower() == expected.lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _get_cabextract(self) -> Optional[str]:
|
||||
if os.environ.get('APPDIR'):
|
||||
candidate = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'cabextract')
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
dev_candidate = str(Path(__file__).parent.parent.parent / 'tools' / 'cabextract')
|
||||
if os.path.isfile(dev_candidate) and os.access(dev_candidate, os.X_OK):
|
||||
return dev_candidate
|
||||
return shutil.which('cabextract')
|
||||
|
||||
def _install_registry_write(self, component: str) -> bool:
|
||||
if component != "fontsmooth=rgb":
|
||||
return False
|
||||
return self._direct_reg_write(
|
||||
r'Control Panel\Desktop',
|
||||
{
|
||||
'FontSmoothing': '"2"',
|
||||
'FontSmoothingType': 'dword:00000002',
|
||||
'FontSmoothingGamma': 'dword:00000578',
|
||||
},
|
||||
)
|
||||
|
||||
def _install_dll_copy(self, component: str) -> bool:
|
||||
if component != "d3dcompiler_47":
|
||||
return False
|
||||
cache_dir = get_jackify_data_dir() / 'component_cache' / 'd3dcompiler'
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
syswow64 = Path(self.wineprefix) / 'drive_c' / 'windows' / 'syswow64'
|
||||
system32 = Path(self.wineprefix) / 'drive_c' / 'windows' / 'system32'
|
||||
syswow64.mkdir(parents=True, exist_ok=True)
|
||||
system32.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for url, sha256, dest, fname in [
|
||||
(_D3DCOMPILER_47_X86_URL, _D3DCOMPILER_47_X86_SHA256, syswow64, 'd3dcompiler_47_32.dll'),
|
||||
(_D3DCOMPILER_47_X64_URL, _D3DCOMPILER_47_X64_SHA256, system32, 'd3dcompiler_47.dll'),
|
||||
]:
|
||||
cached = cache_dir / fname
|
||||
if not self._download_file(url, cached, sha256):
|
||||
return False
|
||||
if not self._verify_sha256(cached, sha256):
|
||||
self.logger.error("SHA256 mismatch on %s after download", fname)
|
||||
cached.unlink()
|
||||
return False
|
||||
shutil.copy2(cached, dest / 'd3dcompiler_47.dll')
|
||||
return True
|
||||
|
||||
def _install_directx_cab(self, component: str) -> bool:
|
||||
cfg = _DX_CFG.get(component)
|
||||
if not cfg:
|
||||
return False
|
||||
cabextract = self._get_cabextract()
|
||||
if not cabextract:
|
||||
self.logger.warning("cabextract not available for %s", component)
|
||||
return False
|
||||
|
||||
cache_dir = get_jackify_data_dir() / 'component_cache' / 'directx'
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
redist = cache_dir / 'directx_Jun2010_redist.exe'
|
||||
if not self._download_file(_DIRECTX_CAB_URL, redist, _DIRECTX_CAB_SHA256):
|
||||
return False
|
||||
if not self._verify_sha256(redist, _DIRECTX_CAB_SHA256):
|
||||
self.logger.error("SHA256 mismatch on DirectX redistributable")
|
||||
redist.unlink()
|
||||
return False
|
||||
|
||||
x86_patterns, x64_patterns, x86_dll_filters, x64_dll_filters, needs_regsvr32 = cfg
|
||||
syswow64, system32 = self._get_system_dirs()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
for arch_tag, stage1_patterns, dll_filters, dest_dir in [
|
||||
('x86', x86_patterns, x86_dll_filters, syswow64),
|
||||
('x64', x64_patterns, x64_dll_filters, system32),
|
||||
]:
|
||||
if not stage1_patterns:
|
||||
continue
|
||||
arch_tmp = tmpdir_path / arch_tag
|
||||
arch_tmp.mkdir()
|
||||
|
||||
for pattern in stage1_patterns:
|
||||
subprocess.run(
|
||||
[cabextract, '-d', str(arch_tmp), '-L', '-F', pattern, str(redist)],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
inner_cabs = list(arch_tmp.glob('*.cab'))
|
||||
if not inner_cabs:
|
||||
self.logger.error("No inner cabs found for %s %s", component, arch_tag)
|
||||
return False
|
||||
|
||||
for dll_filter in dll_filters:
|
||||
for inner_cab in inner_cabs:
|
||||
subprocess.run(
|
||||
[cabextract, '-d', str(dest_dir), '-L', '-F', dll_filter, str(inner_cab)],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
if needs_regsvr32:
|
||||
com_dlls = list(syswow64.glob('xactengine*.dll')) + list(syswow64.glob('xaudio*.dll'))
|
||||
com_dlls += list(system32.glob('xactengine*.dll')) + list(system32.glob('xaudio*.dll'))
|
||||
self._register_xact_com(com_dlls)
|
||||
return True
|
||||
|
||||
def _install_vcrun2022(self) -> bool:
|
||||
cabextract = self._get_cabextract()
|
||||
if not cabextract:
|
||||
self.logger.warning("cabextract not available for vcrun2022")
|
||||
return False
|
||||
|
||||
cache_dir = get_jackify_data_dir() / 'component_cache' / 'vcrun'
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
x86 = cache_dir / 'vc_redist.x86.exe'
|
||||
x64 = cache_dir / 'vc_redist.x64.exe'
|
||||
if not self._download_file(_VCRUN2022_X86_URL, x86):
|
||||
return False
|
||||
if not self._download_file(_VCRUN2022_X64_URL, x64):
|
||||
return False
|
||||
|
||||
syswow64, system32 = self._get_system_dirs()
|
||||
# x86 inner cab is 'a10', x64 inner cab is 'a12'
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
for exe, cab, dest, dlls in [(x86, 'a10', syswow64, _VCRUN2022_DLLS_X86),
|
||||
(x64, 'a12', system32, _VCRUN2022_DLLS_X64)]:
|
||||
arch_tmp = Path(tmpdir) / cab
|
||||
arch_tmp.mkdir()
|
||||
subprocess.run([cabextract, '-d', str(arch_tmp), '-F', cab, str(exe)], capture_output=True)
|
||||
inner_cab = arch_tmp / cab
|
||||
if not inner_cab.is_file():
|
||||
self.logger.error("vcrun2022: inner cab '%s' not found in %s", cab, exe.name)
|
||||
return False
|
||||
for dll_name in dlls:
|
||||
subprocess.run([cabextract, '-d', str(dest), '-F', dll_name, str(inner_cab)], capture_output=True)
|
||||
if not (dest / 'msvcp140.dll').is_file():
|
||||
self.logger.error("vcrun2022: msvcp140.dll not extracted to %s", dest)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _install_vcrun2012(self) -> bool:
|
||||
cabextract = self._get_cabextract()
|
||||
if not cabextract:
|
||||
self.logger.warning("cabextract not available for vcrun2012")
|
||||
return False
|
||||
cache_dir = get_jackify_data_dir() / 'component_cache' / 'vcrun2012'
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
x86 = cache_dir / 'vcredist_x86.exe'
|
||||
x64 = cache_dir / 'vcredist_x64.exe'
|
||||
if not self._download_file(_VCRUN2012_X86_URL, x86):
|
||||
return False
|
||||
if not self._download_file(_VCRUN2012_X64_URL, x64):
|
||||
return False
|
||||
syswow64, system32 = self._get_system_dirs()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
for exe, dest in [(x86, syswow64), (x64, system32)]:
|
||||
for cab_name in ('a2', 'a3'):
|
||||
td = Path(tmpdir) / (exe.stem + cab_name)
|
||||
td.mkdir()
|
||||
subprocess.run([cabextract, '-d', str(td), '-F', cab_name, str(exe)], capture_output=True)
|
||||
inner = td / cab_name
|
||||
if not inner.is_file():
|
||||
continue
|
||||
dd = td / 'x'
|
||||
dd.mkdir()
|
||||
subprocess.run([cabextract, '-d', str(dd), '-L', '-F', 'F_CENTRAL_*', str(inner)], capture_output=True)
|
||||
for src in dd.iterdir():
|
||||
if src.name.startswith('f_central_'):
|
||||
shutil.copy2(src, dest / (src.name[10:].rsplit('_', 1)[0] + '.dll'))
|
||||
if not (syswow64 / 'msvcr110.dll').is_file():
|
||||
self.logger.error("vcrun2012: msvcr110.dll not extracted to syswow64")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _install_dotnet_modern(self, component: str) -> bool:
|
||||
urls = self._get_dotnet_urls(component)
|
||||
if not urls:
|
||||
return False
|
||||
x86_url, x64_url = urls
|
||||
cache_dir = get_jackify_data_dir() / 'component_cache' / 'dotnet'
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
x86_zip = cache_dir / Path(x86_url).name
|
||||
x64_zip = cache_dir / Path(x64_url).name
|
||||
if not self._download_file(x86_url, x86_zip):
|
||||
return False
|
||||
if not self._download_file(x64_url, x64_zip):
|
||||
return False
|
||||
pfx = Path(self.wineprefix) / 'drive_c'
|
||||
for zip_path, dest in [(x86_zip, pfx / 'Program Files (x86)' / 'dotnet'),
|
||||
(x64_zip, pfx / 'Program Files' / 'dotnet')]:
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
zf.extractall(dest)
|
||||
except Exception as exc:
|
||||
self.logger.error("%s zip extraction failed for %s: %s", component, zip_path.name, exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_dotnet_urls(self, component: str) -> Optional[Tuple[str, str]]:
|
||||
manifest = Path(__file__).parent.parent / 'data' / 'native_components_versions.json'
|
||||
try:
|
||||
entry = json.loads(manifest.read_text()).get(component, {})
|
||||
x86 = entry.get('x86_zip_url', '')
|
||||
x64 = entry.get('x64_zip_url', '')
|
||||
if x86 and x64:
|
||||
return x86, x64
|
||||
except Exception as exc:
|
||||
self.logger.error("Could not load dotnet URLs for %s: %s", component, exc)
|
||||
self.logger.error("No zip URLs for %s in versions manifest", component)
|
||||
return None
|
||||
|
||||
def _get_system_dirs(self) -> Tuple[Path, Path]:
|
||||
s64 = Path(self.wineprefix) / 'drive_c' / 'windows' / 'syswow64'
|
||||
s32 = Path(self.wineprefix) / 'drive_c' / 'windows' / 'system32'
|
||||
s64.mkdir(parents=True, exist_ok=True)
|
||||
s32.mkdir(parents=True, exist_ok=True)
|
||||
return s64, s32
|
||||
|
||||
def _register_xact_com(self, dlls: List[Path]) -> None:
|
||||
manifest = Path(__file__).parent.parent / 'data' / 'native_components_versions.json'
|
||||
try:
|
||||
clsid_map = json.loads(manifest.read_text()).get('xact_clsids', {})
|
||||
except Exception:
|
||||
clsid_map = {}
|
||||
for dll_path in dlls:
|
||||
for clsid in clsid_map.get(dll_path.name.lower(), []):
|
||||
dir_name = dll_path.parent.name.lower()
|
||||
win_path = f'"C:\\\\windows\\\\{dir_name}\\\\{dll_path.name}"'
|
||||
self._direct_reg_write(
|
||||
f'Software\\Classes\\CLSID\\{clsid}\\InprocServer32',
|
||||
{'@': win_path, 'ThreadingModel': '"Both"'},
|
||||
)
|
||||
|
||||
def _direct_reg_write(self, key_hkcu: str, values: Dict[str, str]) -> bool:
|
||||
"""Write values to user.reg without spawning Wine. Later sections take precedence, so append is correct."""
|
||||
user_reg = Path(self.wineprefix) / 'user.reg'
|
||||
if not user_reg.is_file():
|
||||
self.logger.warning("user.reg not found at %s", user_reg)
|
||||
return False
|
||||
key_fmted = key_hkcu.replace('\\', '\\\\')
|
||||
try:
|
||||
with open(user_reg, 'a', encoding='utf-8') as f:
|
||||
f.write(f'\n[{key_fmted}] {int(time.time())}\n')
|
||||
for name, val in values.items():
|
||||
f.write(f'@={val}\n' if name == '@' else f'"{name}"={val}\n')
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.logger.error("Direct registry write failed for %s: %s", key_hkcu, exc)
|
||||
return False
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from .path_handler_mo2 import (
|
||||
PathHandlerMO2Mixin,
|
||||
TARGET_EXECUTABLES_LOWER,
|
||||
STOCK_GAME_FOLDERS,
|
||||
SDCARD_PREFIX,
|
||||
)
|
||||
from .path_handler_dxvk import PathHandlerDXVKMixin
|
||||
from .path_handler_steam import PathHandlerSteamMixin
|
||||
@@ -20,7 +19,6 @@ __all__ = [
|
||||
'PathHandler',
|
||||
'TARGET_EXECUTABLES_LOWER',
|
||||
'STOCK_GAME_FOLDERS',
|
||||
'SDCARD_PREFIX',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -131,31 +131,31 @@ class PathHandlerGameMixin:
|
||||
|
||||
@classmethod
|
||||
def find_vanilla_game_paths(cls, game_names=None) -> Dict[str, Path]:
|
||||
"""For each known game, iterate all Steam libraries and look for the canonical game directory in steamapps/common."""
|
||||
GAME_DIR_NAMES = {
|
||||
"Skyrim Special Edition": ["Skyrim Special Edition"],
|
||||
"Fallout 4": ["Fallout 4"],
|
||||
"Fallout New Vegas": ["Fallout New Vegas"],
|
||||
"Oblivion": ["Oblivion"],
|
||||
"Fallout 3": ["Fallout 3", "Fallout 3 goty"]
|
||||
"""Locate vanilla game installations via Steam, Heroic GOG, or Heroic Epic."""
|
||||
GAME_TYPE_MAP = {
|
||||
"Skyrim Special Edition": "skyrim",
|
||||
"Fallout 4": "fallout4",
|
||||
"Fallout New Vegas": "falloutnv",
|
||||
"Oblivion": "oblivion",
|
||||
"Fallout 3": "fallout3",
|
||||
}
|
||||
if game_names is None:
|
||||
game_names = list(GAME_DIR_NAMES.keys())
|
||||
all_steam_libraries = cls.get_all_steam_library_paths()
|
||||
logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}")
|
||||
game_names = list(GAME_TYPE_MAP.keys())
|
||||
|
||||
from jackify.backend.handlers.vanilla_game_finder import VanillaGameFinder
|
||||
finder = VanillaGameFinder()
|
||||
found_games = {}
|
||||
for game in game_names:
|
||||
possible_names = GAME_DIR_NAMES.get(game, [game])
|
||||
for lib in all_steam_libraries:
|
||||
for name in possible_names:
|
||||
candidate = lib / "steamapps" / "common" / name
|
||||
logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}")
|
||||
if candidate.is_dir():
|
||||
found_games[game] = candidate
|
||||
logger.info(f"Found vanilla game directory for {game}: {candidate}")
|
||||
break
|
||||
if game in found_games:
|
||||
break
|
||||
game_type = GAME_TYPE_MAP.get(game)
|
||||
if not game_type:
|
||||
continue
|
||||
result = finder.find(game_type)
|
||||
if result:
|
||||
path, store = result
|
||||
found_games[game] = path
|
||||
logger.info("Found vanilla game %s via %s at %s", game, store, path)
|
||||
else:
|
||||
logger.debug("No installation found for %s", game)
|
||||
return found_games
|
||||
|
||||
def _detect_stock_game_path(self) -> bool:
|
||||
|
||||
@@ -21,9 +21,10 @@ TARGET_EXECUTABLES_LOWER = [
|
||||
"skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe",
|
||||
"sfse_loader.exe", "obse64_loader.exe", "falloutnv.exe"
|
||||
]
|
||||
STOCK_GAME_FOLDERS = ["Stock Game", "StockGame", "Game Root", "Stock Folder", "Skyrim Stock"]
|
||||
SDCARD_PREFIX = '/run/media/mmcblk0p1/'
|
||||
|
||||
STOCK_GAME_FOLDERS = [
|
||||
"Stock Game", "StockGame", "STOCK GAME", "Stock Game Folder",
|
||||
"Game Root", "Stock Folder", "Skyrim Stock", "root/Skyrim Special Edition",
|
||||
]
|
||||
|
||||
class PathHandlerMO2Mixin:
|
||||
"""Mixin providing ModOrganizer.ini path updates and formatting."""
|
||||
@@ -324,7 +325,8 @@ class PathHandlerMO2Mixin:
|
||||
return False
|
||||
|
||||
def edit_binary_working_paths(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool,
|
||||
steam_libraries: Optional[List[Path]] = None) -> bool:
|
||||
steam_libraries: Optional[List[Path]] = None,
|
||||
compat_data_path: Optional[Path] = None) -> bool:
|
||||
"""Update all binary paths and working directories in ModOrganizer.ini. Critical, regression-prone."""
|
||||
try:
|
||||
logger.debug(f"Updating binary paths and working directories in {modlist_ini_path} to use root: {modlist_dir_path}")
|
||||
@@ -352,17 +354,32 @@ class PathHandlerMO2Mixin:
|
||||
logger.debug(f"Extracted existing gamePath: {existing_game_path}, drive letter: {gamepath_drive_letter}")
|
||||
break
|
||||
if modlist_sdcard and existing_game_path and existing_game_path.startswith('/run/media') and gamepath_line_index != -1:
|
||||
sdcard_pattern = r'^/run/media/deck/[^/]+(/Games/.*)$'
|
||||
match = re.match(sdcard_pattern, existing_game_path)
|
||||
if match:
|
||||
stripped_path = match.group(1)
|
||||
windows_path = stripped_path.replace('/', '\\\\')
|
||||
stripped_path = None
|
||||
if compat_data_path:
|
||||
dosdevices_d = compat_data_path / "pfx" / "dosdevices" / "d:"
|
||||
if dosdevices_d.exists():
|
||||
try:
|
||||
d_target = os.readlink(str(dosdevices_d))
|
||||
d_mount = Path(d_target).as_posix().rstrip('/')
|
||||
if existing_game_path.startswith(d_mount):
|
||||
stripped_path = existing_game_path[len(d_mount):]
|
||||
logger.debug(f"Resolved SD card D: mount via dosdevices: {d_mount}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read dosdevices/d: symlink: {e}")
|
||||
if stripped_path is None:
|
||||
# Strip the /run/media/deck/<volume> prefix to get the relative path
|
||||
sdcard_pattern = r'^/run/media/[^/]+/[^/]+(.+)$'
|
||||
m = re.match(sdcard_pattern, existing_game_path)
|
||||
if m:
|
||||
stripped_path = m.group(1)
|
||||
else:
|
||||
logger.warning(f"Could not strip SD card prefix from gamePath (no dosdevices symlink available): {existing_game_path}")
|
||||
if stripped_path is not None:
|
||||
windows_path = stripped_path.lstrip('/').replace('/', '\\\\')
|
||||
new_gamepath_value = f"D:\\\\{windows_path}"
|
||||
new_gamepath_line = f"gamePath = @ByteArray({new_gamepath_value})\n"
|
||||
logger.info(f"Updating gamePath for SD card: {lines[gamepath_line_index].strip()} -> {new_gamepath_line.strip()}")
|
||||
lines[gamepath_line_index] = new_gamepath_line
|
||||
else:
|
||||
logger.warning(f"SD card path doesn't match expected pattern: {existing_game_path}")
|
||||
game_path_updated = False
|
||||
binary_paths_updated = 0
|
||||
working_dirs_updated = 0
|
||||
@@ -601,6 +618,12 @@ class PathHandlerMO2Mixin:
|
||||
if not m:
|
||||
continue
|
||||
raw = m.group(1).strip()
|
||||
ba = re.match(r'@ByteArray\((.+)\)$', raw)
|
||||
if ba:
|
||||
raw = ba.group(1).strip()
|
||||
# Engine's RemapMO2File writes a raw Linux path directly
|
||||
if raw.startswith('/'):
|
||||
return raw
|
||||
# Expect Z:\\path\\... or D:\\path\\... (MO2 doubles backslashes in the file)
|
||||
drive_m = re.match(r'^([ZzDd]):(.+)$', raw)
|
||||
if not drive_m:
|
||||
|
||||
@@ -166,11 +166,11 @@ class PathHandlerSteamMixin:
|
||||
if r not in seen and r != main_resolved:
|
||||
seen.add(r)
|
||||
result.append(r)
|
||||
for extra in (install_dir, download_dir):
|
||||
mp = self.get_mountpoint(extra) if extra else None
|
||||
if mp and mp not in seen:
|
||||
seen.add(mp)
|
||||
result.append(mp)
|
||||
if download_dir:
|
||||
p = str(Path(str(download_dir)).resolve())
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
result.append(p)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -179,6 +179,8 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
if file_prog:
|
||||
result.file_progress = file_prog
|
||||
result.has_progress = True
|
||||
if '[FILE_PROGRESS]' in line:
|
||||
result.message = ""
|
||||
# Check if file counter was attached (for extraction or install phases)
|
||||
if hasattr(file_prog, '_file_counter'):
|
||||
result.file_counter = file_prog._file_counter
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
CLF3 Progress Parser
|
||||
|
||||
Parses CLF3 --jackify stdout into InstallationProgress state.
|
||||
Each line is a JSON object with a "type" field matching ProgressEvent variants.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from jackify.shared.progress_models import InstallationPhase, InstallationProgress, FileProgress, OperationType
|
||||
|
||||
_COUNTER_RE = re.compile(r'\((\d+)/(\d+)\)\s*$')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PHASE_MAP = {
|
||||
"Downloading": InstallationPhase.DOWNLOAD,
|
||||
"Validating": InstallationPhase.VALIDATE,
|
||||
"Installing": InstallationPhase.INSTALL,
|
||||
"Extracting": InstallationPhase.INSTALL,
|
||||
"BSA Build": InstallationPhase.INSTALL,
|
||||
"DDS Transform": InstallationPhase.INSTALL,
|
||||
"Finalizing": InstallationPhase.FINALIZE,
|
||||
"Cleanup": InstallationPhase.FINALIZE,
|
||||
}
|
||||
|
||||
|
||||
class CLF3ProgressStateManager:
|
||||
"""
|
||||
Parses CLF3 --progress-json stdout and maintains InstallationProgress state.
|
||||
Implements the same process_line / get_state interface as ProgressStateManager.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.state = InstallationProgress()
|
||||
self.state.phase = InstallationPhase.INITIALIZATION
|
||||
self.state.phase_name = "Starting"
|
||||
self._total_archives: int = 0
|
||||
self._completed_archives: int = 0
|
||||
self._total_directives: int = 0
|
||||
self._completed_directives: int = 0
|
||||
# True once a real DownloadProgress event fires; distinguishes verify-only runs
|
||||
self._seen_actual_download: bool = False
|
||||
# name -> (downloaded, total, speed)
|
||||
self._active_downloads: dict = {}
|
||||
|
||||
def get_state(self) -> InstallationProgress:
|
||||
return self.state
|
||||
|
||||
def reset(self) -> None:
|
||||
self.__init__()
|
||||
|
||||
def process_line(self, line: str) -> bool:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return False
|
||||
if not stripped.startswith('{'):
|
||||
idx = stripped.find('{')
|
||||
if idx < 0:
|
||||
return False
|
||||
stripped = stripped[idx:]
|
||||
try:
|
||||
obj = json.loads(stripped)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
|
||||
event_type = obj.get('type')
|
||||
if not event_type:
|
||||
return False
|
||||
|
||||
handler = _HANDLERS.get(event_type)
|
||||
if handler:
|
||||
return handler(self, obj)
|
||||
return False
|
||||
|
||||
# -- event handlers --
|
||||
|
||||
def _on_download_progress(self, obj: dict) -> bool:
|
||||
name = obj.get('name', '')
|
||||
downloaded = obj.get('downloaded', 0)
|
||||
total = obj.get('total', 0)
|
||||
speed = obj.get('speed', 0.0)
|
||||
self._active_downloads[name] = (downloaded, total, speed)
|
||||
|
||||
self._seen_actual_download = True
|
||||
|
||||
# Preserve "Downloading + Extracting" phase_name if concurrent mode is already active.
|
||||
in_concurrent = (
|
||||
self._total_archives > 0 and
|
||||
self._completed_archives > self._total_archives
|
||||
)
|
||||
if self.state.phase != InstallationPhase.DOWNLOAD:
|
||||
self.state.phase = InstallationPhase.DOWNLOAD
|
||||
self.state.phase_name = "Downloading + Extracting" if in_concurrent else "Downloading"
|
||||
elif self.state.phase_name not in ("Downloading", "Downloading + Extracting"):
|
||||
self.state.phase_name = "Downloading"
|
||||
|
||||
# Rebuild active_files from current download map
|
||||
active_files = []
|
||||
for dl_name, (dl_downloaded, dl_total, dl_speed) in self._active_downloads.items():
|
||||
pct = (dl_downloaded / dl_total * 100.0) if dl_total > 0 else 0.0
|
||||
active_files.append(FileProgress(
|
||||
filename=dl_name,
|
||||
operation=OperationType.DOWNLOAD,
|
||||
percent=pct,
|
||||
current_size=dl_downloaded,
|
||||
total_size=dl_total,
|
||||
speed=dl_speed,
|
||||
))
|
||||
self.state.active_files = active_files
|
||||
|
||||
total_speed = sum(v[2] for v in self._active_downloads.values())
|
||||
speed_mb = total_speed / 1_048_576
|
||||
self.state.message = f"Downloading {len(active_files)} file(s) | {speed_mb:.1f} MB/s"
|
||||
|
||||
if self._total_archives > 0:
|
||||
if in_concurrent:
|
||||
effective_max = self._total_archives * 2
|
||||
self.state.phase_step = self._completed_archives
|
||||
self.state.phase_max_steps = effective_max
|
||||
self.state.overall_percent = min(self._completed_archives / effective_max * 50.0, 50.0)
|
||||
else:
|
||||
self.state.phase_step = self._completed_archives
|
||||
self.state.phase_max_steps = self._total_archives
|
||||
self.state.overall_percent = min(self._completed_archives / self._total_archives * 50.0, 50.0)
|
||||
return True
|
||||
|
||||
def _on_download_complete(self, obj: dict) -> bool:
|
||||
name = obj.get('name', '')
|
||||
self._active_downloads.pop(name, None)
|
||||
self.state.active_files = [f for f in self.state.active_files if f.filename != name]
|
||||
return True
|
||||
|
||||
def _on_archive_complete(self, obj: dict) -> bool:
|
||||
index = obj.get('index', 0)
|
||||
total = obj.get('total', 0)
|
||||
if total:
|
||||
self._total_archives = total
|
||||
actual_total = total or self._total_archives
|
||||
self._completed_archives = index
|
||||
|
||||
# CLF3 emits a single cumulative ArchiveComplete counter spanning both download
|
||||
# and extraction when running concurrently. index > actual_total means extraction
|
||||
# events are being counted on top of the download events.
|
||||
if actual_total > 0 and index > actual_total:
|
||||
extracted = index - actual_total
|
||||
effective_max = actual_total * 2
|
||||
self.state.phase_step = index
|
||||
self.state.phase_max_steps = effective_max
|
||||
self.state.overall_percent = min(index / effective_max * 50.0, 50.0)
|
||||
self.state.phase_name = "Downloading + Extracting"
|
||||
self.state.message = f"Downloaded: {actual_total}/{actual_total} | Extracting: {extracted}/{actual_total}"
|
||||
elif not self._seen_actual_download and self.state.phase in (InstallationPhase.DOWNLOAD, InstallationPhase.VALIDATE):
|
||||
self.state.phase_step = index
|
||||
self.state.phase_max_steps = actual_total
|
||||
if actual_total > 0:
|
||||
self.state.overall_percent = min(index / actual_total * 50.0, 50.0)
|
||||
self.state.phase_name = "Verifying Archives"
|
||||
self.state.message = f"Verifying: {index}/{actual_total}"
|
||||
else:
|
||||
self.state.phase_step = index
|
||||
self.state.phase_max_steps = actual_total
|
||||
phase_name = self.state.phase_name or ""
|
||||
phase_lower = phase_name.lower()
|
||||
if "bsa" in phase_lower:
|
||||
self.state.message = f"Building BSA: {index}/{actual_total}"
|
||||
elif "dds" in phase_lower or "transform" in phase_lower:
|
||||
self.state.message = f"Converting textures: {index}/{actual_total}"
|
||||
elif "extract" in phase_lower:
|
||||
if actual_total > 0:
|
||||
self.state.overall_percent = min(index / actual_total * 50.0, 50.0)
|
||||
self.state.message = f"Extracting: {index}/{actual_total}"
|
||||
else:
|
||||
if actual_total > 0:
|
||||
self.state.overall_percent = min(index / actual_total * 50.0, 50.0)
|
||||
self.state.message = f"Downloaded: {index}/{actual_total}"
|
||||
return True
|
||||
|
||||
def _on_download_skipped(self, obj: dict) -> bool:
|
||||
count = obj.get('count', 0)
|
||||
# ArchiveComplete tracks the authoritative cumulative index; don't double-count here
|
||||
self.state.message = f"Skipped {count} already-downloaded archive(s)"
|
||||
return True
|
||||
|
||||
def _on_phase_change(self, obj: dict) -> bool:
|
||||
phase_label = obj.get('phase', '')
|
||||
phase = InstallationPhase.UNKNOWN
|
||||
for key, val in _PHASE_MAP.items():
|
||||
if key.lower() in phase_label.lower():
|
||||
phase = val
|
||||
break
|
||||
self.state.phase = phase
|
||||
# Default DOWNLOAD phase to "Verifying"; _on_download_progress flips it
|
||||
# to "Downloading" the first time an actual download event arrives.
|
||||
if phase == InstallationPhase.DOWNLOAD:
|
||||
self.state.phase_name = "Verifying"
|
||||
self.state.message = "Verifying"
|
||||
else:
|
||||
self.state.phase_name = phase_label
|
||||
self.state.message = phase_label
|
||||
self.state.phase_step = 0
|
||||
self.state.phase_max_steps = 0
|
||||
self.state.active_files = []
|
||||
self._active_downloads.clear()
|
||||
self._seen_actual_download = False
|
||||
logger.debug("CLF3 phase: %s -> %s", phase_label, phase)
|
||||
return True
|
||||
|
||||
def _on_directive_complete(self, obj: dict) -> bool:
|
||||
index = obj.get('index', 0)
|
||||
total = obj.get('total', 0)
|
||||
if total:
|
||||
self._total_directives = total
|
||||
self._completed_directives = index
|
||||
self.state.phase_step = index
|
||||
self.state.phase_max_steps = total or self._total_directives
|
||||
if total > 0:
|
||||
self.state.overall_percent = 50.0 + min(index / total * 50.0, 50.0)
|
||||
effective_total = total or self._total_directives
|
||||
phase_lower = (self.state.phase_name or "").lower()
|
||||
if "bsa" in phase_lower:
|
||||
self.state.message = f"Building BSA: {index}/{effective_total}"
|
||||
elif "dds" in phase_lower or "transform" in phase_lower:
|
||||
self.state.message = f"Converting textures: {index}/{effective_total}"
|
||||
else:
|
||||
self.state.message = f"Installing: {index}/{effective_total} files"
|
||||
return True
|
||||
|
||||
def _on_directive_phase_started(self, obj: dict) -> bool:
|
||||
directive_type = obj.get('directive_type', '')
|
||||
total = obj.get('total', 0)
|
||||
if total:
|
||||
self._total_directives = total
|
||||
self.state.message = f"Processing {directive_type} ({total} files)"
|
||||
if self.state.phase not in (InstallationPhase.INSTALL, InstallationPhase.FINALIZE):
|
||||
self.state.phase = InstallationPhase.INSTALL
|
||||
self.state.phase_name = "Installing"
|
||||
return True
|
||||
|
||||
def _on_status(self, obj: dict) -> bool:
|
||||
message = obj.get('message', '')
|
||||
if not message:
|
||||
return False
|
||||
self.state.message = message
|
||||
m = _COUNTER_RE.search(message)
|
||||
if m:
|
||||
step = int(m.group(1))
|
||||
total = int(m.group(2))
|
||||
self.state.phase_step = step
|
||||
self.state.phase_max_steps = total
|
||||
if total > 0:
|
||||
# Streaming extraction emits "Extracting <name> (N/total)" Status messages
|
||||
# instead of phase_start+overall_inc. Detect these and drive 50-100% progress.
|
||||
# Only transition to INSTALL when no downloads are active; during concurrent
|
||||
# download+extract we stay in DOWNLOAD phase and track 0-50%.
|
||||
if message.startswith("Extracting ") and not self._active_downloads:
|
||||
self.state.phase = InstallationPhase.INSTALL
|
||||
self.state.phase_name = "Extracting"
|
||||
self.state.overall_percent = 50.0 + min(step / total * 50.0, 50.0)
|
||||
elif self.state.phase == InstallationPhase.INSTALL:
|
||||
self.state.overall_percent = 50.0 + min(step / total * 50.0, 50.0)
|
||||
else:
|
||||
self.state.overall_percent = min(step / total * 50.0, 50.0)
|
||||
return True
|
||||
|
||||
|
||||
_HANDLERS = {
|
||||
'DownloadProgress': CLF3ProgressStateManager._on_download_progress,
|
||||
'DownloadComplete': CLF3ProgressStateManager._on_download_complete,
|
||||
'ArchiveComplete': CLF3ProgressStateManager._on_archive_complete,
|
||||
'DownloadSkipped': CLF3ProgressStateManager._on_download_skipped,
|
||||
'PhaseChange': CLF3ProgressStateManager._on_phase_change,
|
||||
'DirectiveComplete': CLF3ProgressStateManager._on_directive_complete,
|
||||
'DirectivePhaseStarted': CLF3ProgressStateManager._on_directive_phase_started,
|
||||
'Status': CLF3ProgressStateManager._on_status,
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
"""
|
||||
Example usage of ProgressParser
|
||||
|
||||
This file demonstrates how to use the progress parser to extract
|
||||
structured information from jackify-engine output.
|
||||
|
||||
R&D NOTE: This is experimental code for investigation purposes.
|
||||
"""
|
||||
|
||||
from jackify.backend.handlers.progress_parser import ProgressStateManager
|
||||
|
||||
|
||||
def example_usage():
|
||||
"""Example of how to use the progress parser."""
|
||||
|
||||
# Create state manager
|
||||
state_manager = ProgressStateManager()
|
||||
|
||||
# Simulate processing lines from jackify-engine output
|
||||
sample_lines = [
|
||||
"[00:00:00] === Installing files ===",
|
||||
"[00:00:05] [12/14] Installing files (1.1GB/56.3GB)",
|
||||
"[00:00:10] Installing: Enderal Remastered Armory.7z (42%)",
|
||||
"[00:00:15] Extracting: Mandragora Sprouts.7z (96%)",
|
||||
"[00:00:20] Downloading at 45.2MB/s",
|
||||
"[00:00:25] Extracting at 267.3MB/s",
|
||||
"[00:00:30] Progress: 85%",
|
||||
]
|
||||
|
||||
print("Processing sample output lines...\n")
|
||||
|
||||
for line in sample_lines:
|
||||
updated = state_manager.process_line(line)
|
||||
if updated:
|
||||
state = state_manager.get_state()
|
||||
print(f"Line: {line}")
|
||||
print(f" Phase: {state.phase.value} - {state.phase_name}")
|
||||
print(f" Progress: {state.overall_percent:.1f}%")
|
||||
print(f" Step: {state.phase_progress_text}")
|
||||
print(f" Data: {state.data_progress_text}")
|
||||
print(f" Active Files: {len(state.active_files)}")
|
||||
for file_prog in state.active_files:
|
||||
print(f" - {file_prog.filename}: {file_prog.percent:.1f}%")
|
||||
print(f" Speeds: {state.speeds}")
|
||||
print(f" Display: {state.display_text}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
example_usage()
|
||||
|
||||
@@ -11,6 +11,128 @@ logger = logging.getLogger(__name__)
|
||||
class ShortcutLaunchOptionsMixin:
|
||||
"""Mixin providing launch options and icon methods."""
|
||||
|
||||
def get_shortcut_launch_options(self, app_name: str, exe_path: str) -> 'Optional[str]':
|
||||
"""Return current LaunchOptions for a shortcut, or None if the shortcut is not found."""
|
||||
shortcuts_file = self.path_handler._find_shortcuts_vdf()
|
||||
if not shortcuts_file or not os.path.exists(shortcuts_file):
|
||||
return None
|
||||
try:
|
||||
with open(shortcuts_file, 'rb') as f:
|
||||
data = vdf.binary_loads(f.read())
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not read shortcuts.vdf: {e}")
|
||||
return None
|
||||
|
||||
def _norm(p: str) -> str:
|
||||
try:
|
||||
return os.path.normpath(os.path.abspath(p.strip().strip('"'))).lower()
|
||||
except Exception:
|
||||
return p.strip().strip('"').lower()
|
||||
|
||||
exe_norm = _norm(exe_path)
|
||||
for shortcut_data in data.get('shortcuts', {}).values():
|
||||
if (shortcut_data.get('AppName', '').strip() == app_name and
|
||||
_norm(shortcut_data.get('Exe', '')) == exe_norm):
|
||||
return shortcut_data.get('LaunchOptions', '')
|
||||
return None
|
||||
|
||||
def ensure_mounts_in_steam_compat(self, app_name: str, exe_path: str, *paths: str) -> str:
|
||||
"""Add mountpoints of any supplied paths to STEAM_COMPAT_MOUNTS if not already present.
|
||||
|
||||
Reads existing launch options and appends only what is missing — never overwrites
|
||||
unrelated options. Adds the top-level directory of each path so Proton's container
|
||||
can bind-mount the subtree into the prefix.
|
||||
|
||||
When Steam is running, the write is deferred: returns "steam_running" so the caller
|
||||
can stop Steam first, call apply_pending_mounts_update(), then restart Steam.
|
||||
|
||||
Returns:
|
||||
"unchanged" — mounts already correct, no action needed
|
||||
"updated" — Steam was not running; write succeeded
|
||||
"steam_running" — changes needed but deferred; call apply_pending_mounts_update()
|
||||
after stopping Steam
|
||||
"failed" — shortcut not found or write error
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path as _Path
|
||||
|
||||
def _is_covered(path: str, mounts: list) -> bool:
|
||||
"""Return True if path is already reachable via an existing mount entry.
|
||||
|
||||
A path is covered if an existing mount entry is equal to it or is a
|
||||
parent of it. Root '/' is excluded as a catch-all.
|
||||
"""
|
||||
p = _Path(path)
|
||||
for mount in mounts:
|
||||
if mount == '/':
|
||||
continue
|
||||
try:
|
||||
p.relative_to(mount)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
current = self.get_shortcut_launch_options(app_name, exe_path)
|
||||
if current is None:
|
||||
self.logger.warning(f"Shortcut '{app_name}' not found in shortcuts.vdf; cannot update STEAM_COMPAT_MOUNTS")
|
||||
return "failed"
|
||||
|
||||
compat_re = re.compile(r'STEAM_COMPAT_MOUNTS="([^"]*)"')
|
||||
m = compat_re.search(current)
|
||||
existing = [p for p in m.group(1).split(':') if p] if m else []
|
||||
|
||||
mounts_to_add = []
|
||||
for p in paths:
|
||||
if not p:
|
||||
continue
|
||||
if not _is_covered(p, existing) and p not in mounts_to_add:
|
||||
mounts_to_add.append(p)
|
||||
|
||||
if not mounts_to_add:
|
||||
self.logger.debug(f"STEAM_COMPAT_MOUNTS for '{app_name}' already covers required paths")
|
||||
return "unchanged"
|
||||
|
||||
if m:
|
||||
updated_val = ':'.join(existing + mounts_to_add)
|
||||
updated = compat_re.sub(f'STEAM_COMPAT_MOUNTS="{updated_val}"', current)
|
||||
else:
|
||||
val = ':'.join(mounts_to_add)
|
||||
prefix = f'STEAM_COMPAT_MOUNTS="{val}"'
|
||||
updated = f'{prefix} {current}' if current.strip() else f'{prefix} %command%'
|
||||
|
||||
self.logger.info(f"STEAM_COMPAT_MOUNTS update needed for '{app_name}': adding {mounts_to_add}")
|
||||
|
||||
try:
|
||||
from jackify.backend.services.steam_restart_service import get_steam_processes
|
||||
steam_running = bool(get_steam_processes())
|
||||
except Exception:
|
||||
steam_running = False
|
||||
|
||||
if steam_running:
|
||||
# Defer the write — Steam holds shortcuts.vdf in memory and would clobber it.
|
||||
# Store the pending options so the GUI can stop Steam, apply, then restart.
|
||||
self._pending_mounts_app_name = app_name
|
||||
self._pending_mounts_exe_path = exe_path
|
||||
self._pending_mounts_options = updated
|
||||
return "steam_running"
|
||||
|
||||
success = self.update_shortcut_launch_options(app_name, exe_path, updated)
|
||||
return "updated" if success else "failed"
|
||||
|
||||
def apply_pending_mounts_update(self) -> bool:
|
||||
"""Write a deferred STEAM_COMPAT_MOUNTS update. Call only after Steam has stopped."""
|
||||
app_name = getattr(self, '_pending_mounts_app_name', None)
|
||||
exe_path = getattr(self, '_pending_mounts_exe_path', None)
|
||||
options = getattr(self, '_pending_mounts_options', None)
|
||||
if not (app_name and exe_path and options):
|
||||
self.logger.warning("apply_pending_mounts_update called with no pending update")
|
||||
return False
|
||||
self._pending_mounts_app_name = None
|
||||
self._pending_mounts_exe_path = None
|
||||
self._pending_mounts_options = None
|
||||
return self.update_shortcut_launch_options(app_name, exe_path, options)
|
||||
|
||||
def update_shortcut_launch_options(self, app_name, exe_path, new_launch_options):
|
||||
"""
|
||||
Updates the LaunchOptions for a specific existing shortcut in shortcuts.vdf by matching AppName and Exe.
|
||||
|
||||
@@ -180,7 +180,7 @@ class ProcessManager:
|
||||
"""
|
||||
Shared process manager for robust subprocess launching, tracking, and cancellation.
|
||||
"""
|
||||
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False):
|
||||
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False, use_pty=False):
|
||||
self.cmd = cmd
|
||||
# Default to cleaned environment if None to prevent AppImage variable inheritance
|
||||
if env is None:
|
||||
@@ -192,6 +192,8 @@ class ProcessManager:
|
||||
self.bufsize = bufsize
|
||||
self.separate_stderr = separate_stderr
|
||||
self.enable_stdin = enable_stdin
|
||||
self.use_pty = use_pty
|
||||
self._pty_master_fd = None
|
||||
self.proc = None
|
||||
self.process_group_pid = None
|
||||
self._stdin_lock = threading.Lock()
|
||||
@@ -200,17 +202,39 @@ class ProcessManager:
|
||||
def _start_process(self):
|
||||
stderr_arg = subprocess.PIPE if self.separate_stderr else subprocess.STDOUT
|
||||
stdin_arg = subprocess.PIPE if self.enable_stdin else None
|
||||
self.proc = subprocess.Popen(
|
||||
self.cmd,
|
||||
stdin=stdin_arg,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=stderr_arg,
|
||||
env=self.env,
|
||||
cwd=self.cwd,
|
||||
text=self.text,
|
||||
bufsize=self.bufsize,
|
||||
start_new_session=True
|
||||
)
|
||||
if self.use_pty:
|
||||
import pty
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
self._pty_master_fd = master_fd
|
||||
# TERM=dumb + NO_COLOR prevent CLF3 from using cursor movement or ANSI
|
||||
# sequences (which would cause in-place overwrites we can't capture).
|
||||
# isatty() still returns True on the slave, so Rust line-buffers stdout.
|
||||
pty_env = dict(self.env) if self.env else {}
|
||||
pty_env['TERM'] = 'dumb'
|
||||
pty_env['NO_COLOR'] = '1'
|
||||
self.proc = subprocess.Popen(
|
||||
self.cmd,
|
||||
stdin=stdin_arg,
|
||||
stdout=slave_fd,
|
||||
stderr=stderr_arg,
|
||||
env=pty_env,
|
||||
cwd=self.cwd,
|
||||
start_new_session=True,
|
||||
pass_fds=(slave_fd,),
|
||||
)
|
||||
os.close(slave_fd)
|
||||
else:
|
||||
self.proc = subprocess.Popen(
|
||||
self.cmd,
|
||||
stdin=stdin_arg,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=stderr_arg,
|
||||
env=self.env,
|
||||
cwd=self.cwd,
|
||||
text=self.text,
|
||||
bufsize=self.bufsize,
|
||||
start_new_session=True,
|
||||
)
|
||||
self.process_group_pid = os.getpgid(self.proc.pid)
|
||||
|
||||
def cancel(self, timeout_terminate=2, timeout_kill=1, max_cleanup_attempts=3):
|
||||
@@ -267,6 +291,12 @@ class ProcessManager:
|
||||
cleanup_attempts += 1
|
||||
finally:
|
||||
# Always close pipes - unblocks threads blocked on read(1) or iterating stderr
|
||||
if self._pty_master_fd is not None:
|
||||
try:
|
||||
os.close(self._pty_master_fd)
|
||||
except Exception:
|
||||
pass
|
||||
self._pty_master_fd = None
|
||||
if self.proc:
|
||||
for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
|
||||
if pipe:
|
||||
@@ -289,6 +319,11 @@ class ProcessManager:
|
||||
return None
|
||||
|
||||
def read_stdout_char(self):
|
||||
if self._pty_master_fd is not None:
|
||||
try:
|
||||
return os.read(self._pty_master_fd, 1)
|
||||
except (OSError, IOError):
|
||||
return None
|
||||
if self.proc and self.proc.stdout:
|
||||
try:
|
||||
return self.proc.stdout.read(1)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
TTW installer backend: install_ttw_backend, start_ttw_installation, cleanup, stream output, integrate.
|
||||
"""
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
@@ -144,7 +146,8 @@ class TTWInstallerBackendMixin:
|
||||
exe_dir = str(self.ttw_installer_executable_path.parent)
|
||||
process = subprocess.Popen(
|
||||
cmd, cwd=exe_dir, env=env,
|
||||
stdout=output_fh, stderr=subprocess.STDOUT, bufsize=1
|
||||
stdout=output_fh, stderr=subprocess.STDOUT, bufsize=1,
|
||||
start_new_session=True,
|
||||
)
|
||||
self.logger.info("TTW_Linux_Installer process started (PID: %s), output to %s", process.pid, output_file)
|
||||
process._output_fh = output_fh
|
||||
@@ -155,7 +158,7 @@ class TTWInstallerBackendMixin:
|
||||
|
||||
@staticmethod
|
||||
def cleanup_ttw_process(process):
|
||||
"""Clean up after TTW installation process."""
|
||||
"""Terminate the TTW process group, then clean up file handles."""
|
||||
if process:
|
||||
if hasattr(process, '_output_fh'):
|
||||
try:
|
||||
@@ -164,13 +167,18 @@ class TTWInstallerBackendMixin:
|
||||
pass
|
||||
if process.poll() is None:
|
||||
try:
|
||||
process.terminate()
|
||||
pgid = os.getpgid(process.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
process.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
pgid = os.getpgid(process.pid)
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
from jackify.shared.paths import cleanup_stale_tmp
|
||||
cleanup_stale_tmp()
|
||||
|
||||
|
||||
@@ -74,10 +74,9 @@ class TTWInstallerHandler(TTWInstallerBackendMixin):
|
||||
|
||||
def _check_installation(self):
|
||||
"""Check if TTW_Linux_Installer is installed at expected location.
|
||||
|
||||
|
||||
Checks for both old format (ttw_linux_gui) and new format (mpi_installer) executables.
|
||||
"""
|
||||
self._ensure_dirs_exist()
|
||||
|
||||
# Check for both old (ttw_linux_gui) and new (mpi_installer) executable names
|
||||
exe_names = [TTW_INSTALLER_EXECUTABLE_NAME, "mpi_installer"]
|
||||
@@ -104,7 +103,6 @@ class TTWInstallerHandler(TTWInstallerBackendMixin):
|
||||
(success: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
self._ensure_dirs_exist()
|
||||
target_dir = Path(install_dir) if install_dir else self.ttw_installer_dir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
VanillaGameFinder
|
||||
|
||||
Locates vanilla game installations across Steam and Heroic (GOG/Epic)
|
||||
without requiring manual path entry from the user.
|
||||
|
||||
Detection order: Steam appmanifest -> Heroic GOG -> Heroic Epic.
|
||||
No manual path override is offered here; that belongs in user settings.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, NamedTuple, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Store identifiers returned alongside the detected path.
|
||||
STORE_STEAM = "steam"
|
||||
STORE_GOG = "gog"
|
||||
STORE_EPIC = "epic"
|
||||
STORE_UNKNOWN = "unknown"
|
||||
|
||||
GameLocation = Tuple[Path, str] # (path, store)
|
||||
|
||||
|
||||
class SteamEntry(NamedTuple):
|
||||
app_id: str
|
||||
dir_names: List[str]
|
||||
|
||||
|
||||
# Maps Jackify game_type -> ordered list of Steam candidates to try.
|
||||
# Multiple entries handle games with ambiguous type strings (e.g. skyrim = SSE or LE).
|
||||
_STEAM_CATALOG: Dict[str, List[SteamEntry]] = {
|
||||
'skyrim': [
|
||||
SteamEntry('489830', ['Skyrim Special Edition']),
|
||||
SteamEntry('72850', ['Skyrim']),
|
||||
],
|
||||
'skyrimvr': [SteamEntry('611670', ['Skyrim VR'])],
|
||||
'fallout4': [SteamEntry('377160', ['Fallout 4'])],
|
||||
'fallout4vr': [SteamEntry('611660', ['Fallout 4 VR'])],
|
||||
'falloutnv': [SteamEntry('22380', ['Fallout New Vegas', 'FalloutNV'])],
|
||||
'fallout3': [
|
||||
SteamEntry('22300', ['Fallout 3', 'Fallout3']),
|
||||
SteamEntry('22370', ['Fallout 3 goty', 'Fallout 3 GOTY', 'Fallout3']),
|
||||
],
|
||||
'oblivion': [SteamEntry('22330', ['Oblivion'])],
|
||||
'oblivion_remastered': [SteamEntry('2623190', ['Oblivion Remastered'])],
|
||||
'morrowind': [SteamEntry('22320', ['Morrowind'])],
|
||||
'starfield': [SteamEntry('1716740', ['Starfield'])],
|
||||
'enderal': [
|
||||
SteamEntry('976620', ['Enderal Special Edition']),
|
||||
SteamEntry('933480', ['Enderal Forgotten Stories', 'Enderal']),
|
||||
],
|
||||
'bg3': [SteamEntry('1086940', ['Baldurs Gate 3', "Baldur's Gate 3"])],
|
||||
'cp2077': [SteamEntry('1091500', ['Cyberpunk 2077'])],
|
||||
'witcher3': [SteamEntry('292030', ['The Witcher 3 Wild Hunt', 'The Witcher 3: Wild Hunt'])],
|
||||
'darksouls3': [SteamEntry('374320', ['DARK SOULS III'])],
|
||||
'eldenring': [SteamEntry('1245620', ['ELDEN RING'])],
|
||||
'sekiro': [SteamEntry('814380', ['Sekiro'])],
|
||||
'mountandblade2': [SteamEntry('261550', ['Mount & Blade II Bannerlord'])],
|
||||
'stardewvalley': [SteamEntry('413150', ['Stardew Valley'])],
|
||||
'dragonageinquisition': [SteamEntry('1222690', ['Dragon Age Inquisition'])],
|
||||
'hogwartslegacy': [SteamEntry('990080', ['Hogwarts Legacy'])],
|
||||
}
|
||||
|
||||
# Maps Jackify game_type -> list of GOG app IDs (appName in installed.json).
|
||||
# Source: Fluorine-Manager/libs/basic_games/gog_utils.py approach + CLF3 known_games.rs IDs.
|
||||
_HEROIC_GOG_CATALOG: Dict[str, List[str]] = {
|
||||
'falloutnv': ['1454587428'],
|
||||
'fallout3': ['1454315831'],
|
||||
'oblivion': ['1458058109'],
|
||||
'morrowind': ['1440163901'],
|
||||
'bg3': ['1456460669'],
|
||||
'cp2077': ['1423049311'],
|
||||
'witcher3': ['1495134320'],
|
||||
'skyrim': ['1711230643'],
|
||||
'stardewvalley': ['1453375253'],
|
||||
}
|
||||
|
||||
# Maps Jackify game_type -> list of Epic/Legendary app_name slugs (installed.json key).
|
||||
_HEROIC_EPIC_CATALOG: Dict[str, List[str]] = {
|
||||
'fallout3': ['adeae8bbfc94427db57c7dfecce3f1d4'],
|
||||
'falloutnv': ['5daeb974a22a435988892319b3a4f476'],
|
||||
}
|
||||
|
||||
# Epic installs some games into a language-specific subdirectory inside the install root.
|
||||
# Maps game_type -> glob pattern to find the real game directory one level down.
|
||||
# Language suffix varies (English, German, French, ...) so we glob rather than hardcode.
|
||||
_EPIC_SUBDIR_GLOB: Dict[str, str] = {
|
||||
'fallout3': 'Fallout 3 GOTY *',
|
||||
'falloutnv': 'Fallout New Vegas *',
|
||||
}
|
||||
|
||||
# Candidate Heroic config roots: native install then Flatpak.
|
||||
_HEROIC_CONFIG_ROOTS: List[Path] = [
|
||||
Path.home() / '.config' / 'heroic',
|
||||
Path.home() / '.var' / 'app' / 'com.heroicgameslauncher.hgl' / 'config' / 'heroic',
|
||||
]
|
||||
|
||||
|
||||
class VanillaGameFinder:
|
||||
"""
|
||||
Locates vanilla (store-installed) game directories for a given Jackify game_type.
|
||||
Returns a (Path, store) tuple so callers can warn when the game is not on Steam.
|
||||
Searches Steam first, then Heroic-managed stores (GOG, Epic).
|
||||
"""
|
||||
|
||||
def find(self, game_type: str) -> Optional[GameLocation]:
|
||||
"""
|
||||
Return (path, store) for the detected game installation, or None.
|
||||
store is one of: 'steam', 'gog', 'epic', 'unknown'.
|
||||
"""
|
||||
result = self._find_steam(game_type)
|
||||
if result:
|
||||
logger.info("VanillaGameFinder: found %s via Steam at %s", game_type, result)
|
||||
return result, STORE_STEAM
|
||||
|
||||
result = self._find_heroic(game_type)
|
||||
if result:
|
||||
path, store = result
|
||||
logger.info("VanillaGameFinder: found %s via %s at %s", game_type, store, path)
|
||||
return path, store
|
||||
|
||||
logger.debug("VanillaGameFinder: no installation found for %s", game_type)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Steam
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _find_steam(self, game_type: str) -> Optional[Path]:
|
||||
entries = _STEAM_CATALOG.get(game_type)
|
||||
if not entries:
|
||||
return None
|
||||
try:
|
||||
from jackify.backend.handlers.path_handler_steam import PathHandlerSteamMixin
|
||||
library_paths = PathHandlerSteamMixin.get_all_steam_library_paths()
|
||||
except Exception as e:
|
||||
logger.debug("Steam library path detection failed: %s", e)
|
||||
return None
|
||||
|
||||
for library in library_paths:
|
||||
steamapps = library / 'steamapps'
|
||||
if not steamapps.is_dir():
|
||||
continue
|
||||
for entry in entries:
|
||||
path = self._check_steam_entry(steamapps, entry)
|
||||
if path:
|
||||
return path
|
||||
return None
|
||||
|
||||
def _check_steam_entry(self, steamapps: Path, entry: SteamEntry) -> Optional[Path]:
|
||||
manifest = steamapps / f'appmanifest_{entry.app_id}.acf'
|
||||
if not manifest.is_file():
|
||||
return None
|
||||
try:
|
||||
content = manifest.read_text(encoding='utf-8', errors='replace')
|
||||
state_match = re.search(r'"StateFlags"\s+"(\d+)"', content)
|
||||
if state_match and not (int(state_match.group(1)) & 4):
|
||||
logger.debug("Skipping %s: StateFlags=%s (not fully installed)", manifest.name, state_match.group(1))
|
||||
return None
|
||||
match = re.search(r'"installdir"\s+"([^"]+)"', content)
|
||||
if match:
|
||||
path = steamapps / 'common' / match.group(1)
|
||||
if path.is_dir():
|
||||
return path
|
||||
for name in entry.dir_names:
|
||||
fallback = steamapps / 'common' / name
|
||||
if fallback.is_dir():
|
||||
return fallback
|
||||
except OSError as e:
|
||||
logger.debug("Could not read appmanifest %s: %s", manifest, e)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Heroic (GOG via gog_store/installed.json, Epic via legendaryConfig)
|
||||
# Approach adapted from Fluorine-Manager/libs/basic_games/gog_utils.py
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _find_heroic(self, game_type: str) -> Optional[Tuple[Path, str]]:
|
||||
gog_ids = _HEROIC_GOG_CATALOG.get(game_type)
|
||||
if gog_ids:
|
||||
path = self._find_heroic_gog(gog_ids)
|
||||
if path:
|
||||
return path, STORE_GOG
|
||||
|
||||
epic_ids = _HEROIC_EPIC_CATALOG.get(game_type)
|
||||
if epic_ids:
|
||||
path = self._find_heroic_epic(epic_ids, game_type=game_type)
|
||||
if path:
|
||||
return path, STORE_EPIC
|
||||
|
||||
return None
|
||||
|
||||
def _find_heroic_gog(self, app_ids: List[str]) -> Optional[Path]:
|
||||
id_set = set(app_ids)
|
||||
for config_root in _HEROIC_CONFIG_ROOTS:
|
||||
installed_file = config_root / 'gog_store' / 'installed.json'
|
||||
if not installed_file.is_file():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(installed_file.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.debug("Could not read Heroic GOG installed.json %s: %s", installed_file, e)
|
||||
continue
|
||||
for entry in data.get('installed', []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get('appName') not in id_set:
|
||||
continue
|
||||
install_path = entry.get('install_path') or entry.get('installPath', '')
|
||||
if install_path:
|
||||
path = Path(install_path)
|
||||
if path.is_dir():
|
||||
return path
|
||||
return None
|
||||
|
||||
def _find_heroic_epic(self, app_ids: List[str], game_type: str = '') -> Optional[Path]:
|
||||
id_set = set(app_ids)
|
||||
subdir_glob = _EPIC_SUBDIR_GLOB.get(game_type, '')
|
||||
for config_root in _HEROIC_CONFIG_ROOTS:
|
||||
installed_file = config_root / 'legendaryConfig' / 'legendary' / 'installed.json'
|
||||
if not installed_file.is_file():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(installed_file.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.debug("Could not read Heroic Epic installed.json %s: %s", installed_file, e)
|
||||
continue
|
||||
for app_name, entry in data.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if app_name not in id_set:
|
||||
continue
|
||||
install_path = entry.get('install_path', '')
|
||||
if not install_path:
|
||||
continue
|
||||
path = Path(install_path)
|
||||
if not path.is_dir():
|
||||
continue
|
||||
# Epic installs some titles into a language-specific subdirectory.
|
||||
# Glob for it rather than hardcoding the language suffix.
|
||||
if subdir_glob:
|
||||
matches = sorted(path.glob(subdir_glob))
|
||||
if matches:
|
||||
logger.debug("Epic subdir match for %s: %s", game_type, matches[0])
|
||||
return matches[0]
|
||||
return path
|
||||
return None
|
||||
@@ -382,44 +382,56 @@ class WineUtilsProtonMixin:
|
||||
|
||||
@staticmethod
|
||||
def scan_valve_proton_versions() -> List[Dict[str, Any]]:
|
||||
"""Scan for available Valve Proton versions with fallback priority."""
|
||||
"""Scan for available Valve Proton versions. Discovers all installed X.Y releases dynamically."""
|
||||
logger.info("Scanning for available Valve Proton versions...")
|
||||
found_versions = []
|
||||
seen_names: set = set()
|
||||
steam_libs = WineUtilsProtonMixin.get_steam_library_paths()
|
||||
if not steam_libs:
|
||||
logger.warning("No Steam library paths found")
|
||||
return []
|
||||
preferred_versions = [
|
||||
("Proton - Experimental", 150),
|
||||
("Proton 10.0", 140),
|
||||
("Proton 9.0", 130),
|
||||
("Proton 9.0 (Beta)", 125)
|
||||
]
|
||||
|
||||
for steam_path in steam_libs:
|
||||
logger.debug(f"Scanning Steam library: {steam_path}")
|
||||
for version_name, priority in preferred_versions:
|
||||
proton_path = steam_path / version_name
|
||||
wine_bin = proton_path / "files" / "bin" / "wine"
|
||||
if wine_bin.exists() and wine_bin.is_file():
|
||||
compat_name = WineUtilsProtonMixin.resolve_steam_compat_name(proton_path)
|
||||
found_versions.append({
|
||||
'name': version_name,
|
||||
'path': proton_path,
|
||||
'wine_bin': wine_bin,
|
||||
'priority': priority,
|
||||
'type': 'Valve-Proton',
|
||||
'steam_compat_name': compat_name,
|
||||
})
|
||||
logger.debug(f"Found {version_name} at {proton_path}")
|
||||
found_versions.sort(key=lambda x: x['priority'], reverse=True)
|
||||
unique_versions = []
|
||||
seen_names = set()
|
||||
for version in found_versions:
|
||||
if version['name'] not in seen_names:
|
||||
unique_versions.append(version)
|
||||
seen_names.add(version['name'])
|
||||
logger.info(f"Found {len(unique_versions)} unique Valve Proton version(s)")
|
||||
return unique_versions
|
||||
if not steam_path.is_dir():
|
||||
continue
|
||||
for entry in steam_path.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
name = entry.name
|
||||
wine_bin = entry / "files" / "bin" / "wine"
|
||||
if not wine_bin.is_file():
|
||||
continue
|
||||
if name in seen_names:
|
||||
continue
|
||||
|
||||
if name == "Proton - Experimental":
|
||||
major, minor, is_beta = 9999, 9999, False
|
||||
else:
|
||||
m = re.match(r'^Proton (\d+)\.(\d+)(\s+\(Beta\))?$', name)
|
||||
if not m:
|
||||
continue
|
||||
major, minor, is_beta = int(m.group(1)), int(m.group(2)), bool(m.group(3))
|
||||
|
||||
compat_name = WineUtilsProtonMixin.resolve_steam_compat_name(entry)
|
||||
found_versions.append({
|
||||
'name': name,
|
||||
'path': entry,
|
||||
'wine_bin': wine_bin,
|
||||
'priority': major * 10 + (0 if is_beta else 1),
|
||||
'major_version': major,
|
||||
'minor_version': minor,
|
||||
'type': 'Valve-Proton',
|
||||
'steam_compat_name': compat_name,
|
||||
})
|
||||
seen_names.add(name)
|
||||
logger.debug(f"Found Valve Proton: {name}")
|
||||
|
||||
found_versions.sort(
|
||||
key=lambda x: (x['major_version'], x['minor_version'], 0 if x['name'].endswith('(Beta)') else 1),
|
||||
reverse=True,
|
||||
)
|
||||
logger.info(f"Found {len(found_versions)} Valve Proton version(s)")
|
||||
return found_versions
|
||||
|
||||
@staticmethod
|
||||
def scan_all_proton_versions() -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -7,6 +7,7 @@ Discovery, installation strategy, and verification live in mixins.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -54,6 +55,45 @@ class WinetricksHandler(
|
||||
if not components_to_install:
|
||||
return True
|
||||
|
||||
native_wineprefix = env.get('WINEPREFIX', wineprefix)
|
||||
wine_binary = env.get('WINE', '')
|
||||
|
||||
# Native installer tier: direct-source downloads, no winetricks dependency.
|
||||
native = None
|
||||
if wine_binary:
|
||||
try:
|
||||
from .native_component_installer import NativeComponentInstaller
|
||||
native = NativeComponentInstaller(native_wineprefix, wine_binary, env, self.logger)
|
||||
except Exception as native_exc:
|
||||
self.logger.warning("Native installer init failed: %s", native_exc)
|
||||
|
||||
if native:
|
||||
try:
|
||||
_native_ok, components_to_install = native.install_components(
|
||||
components_to_install, status_callback
|
||||
)
|
||||
if not components_to_install:
|
||||
self._set_windows_10_mode_after_install(native_wineprefix, env)
|
||||
return True
|
||||
except Exception as native_exc:
|
||||
self.logger.warning("Native installer skipped due to error: %s", native_exc)
|
||||
|
||||
# dotnet48 (NSF/CSF modlists) installs via the bundled winetricks verb. Fully remove
|
||||
# Wine Mono first (files + mscoree stub + uninstaller entry + its NDP registry keys) so
|
||||
# the prefix is in the clean "no .NET, no mono" state a fresh winetricks dotnet48 expects:
|
||||
# this avoids the "same or higher version already installed" bail (the NDP keys are
|
||||
# mono-registered, winetricks #2367) and lets winetricks complete the .NET first-run
|
||||
# during the verb, so it does not fire at game launch.
|
||||
# The status message persists through the install (the main winetricks call emits none
|
||||
# before it blocks), so the UI shows dotnet48 rather than the prior native component.
|
||||
if 'dotnet48' in components_to_install:
|
||||
if status_callback:
|
||||
status_callback("[NATIVE_INSTALL] dotnet48")
|
||||
status_callback("Installing .NET Framework 4.8 (dotnet48) - the long step, can take several minutes")
|
||||
self.logger.info("Installing dotnet48 via winetricks (NSF/CSF) - long-running step")
|
||||
self._kill_wineserver_for_prefix(env)
|
||||
self._remove_wine_mono(native_wineprefix)
|
||||
|
||||
# Flatpak Steam: use protontricks only; bundled winetricks is unreliable (e.g. from AppImage)
|
||||
flatpak_steam = False
|
||||
try:
|
||||
@@ -455,6 +495,70 @@ class WinetricksHandler(
|
||||
except Exception as e:
|
||||
self.logger.debug("Wineserver -k failed (non-fatal): %s", e)
|
||||
|
||||
def _remove_wine_mono(self, wineprefix: str) -> None:
|
||||
"""Fully remove Wine Mono so a clean winetricks dotnet48 install behaves as on a fresh
|
||||
prefix - the state that installs without the "already installed" bail and without
|
||||
deferring the .NET first-run to game launch.
|
||||
|
||||
Removes: the mono mscoree.dll stubs, the mono runtime directory, the "Wine Mono"
|
||||
uninstaller registry sections, and the mono-registered NET Framework Setup\\NDP keys
|
||||
(those are what trigger the #2367 bail). Wineserver must be dead so the system.reg
|
||||
edit persists.
|
||||
"""
|
||||
import time
|
||||
time.sleep(2)
|
||||
|
||||
for sub in ('system32', 'syswow64'):
|
||||
dll = os.path.join(wineprefix, 'drive_c', 'windows', sub, 'mscoree.dll')
|
||||
try:
|
||||
if os.path.isfile(dll):
|
||||
with open(dll, 'rb') as f:
|
||||
is_mono_stub = b'WINE_MONO_OVERRIDES' in f.read()
|
||||
if is_mono_stub:
|
||||
os.unlink(dll)
|
||||
self.logger.debug("Removed Mono mscoree.dll stub: %s", dll)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mono_dir = os.path.join(wineprefix, 'drive_c', 'windows', 'mono')
|
||||
if os.path.isdir(mono_dir):
|
||||
shutil.rmtree(mono_dir, ignore_errors=True)
|
||||
self.logger.debug("Removed Wine Mono runtime directory")
|
||||
|
||||
system_reg = os.path.join(wineprefix, 'system.reg')
|
||||
if not os.path.isfile(system_reg):
|
||||
return
|
||||
try:
|
||||
with open(system_reg, encoding='utf-8', errors='replace') as f:
|
||||
lines = f.readlines()
|
||||
out, section, drop = [], [], False
|
||||
removed_mono, removed_ndp = 0, 0
|
||||
for line in lines:
|
||||
if line.lstrip().startswith('['):
|
||||
if section and not drop:
|
||||
out.extend(section)
|
||||
end = line.strip().rfind(']')
|
||||
key = line.strip()[1:end].lower() if end > 0 else ''
|
||||
drop = 'net framework setup\\\\ndp' in key
|
||||
if drop:
|
||||
removed_ndp += 1
|
||||
section = [line]
|
||||
elif section:
|
||||
section.append(line)
|
||||
s = line.strip()
|
||||
if s.startswith('"DisplayName"=') and 'Wine Mono' in s and not drop:
|
||||
drop = True
|
||||
removed_mono += 1
|
||||
else:
|
||||
out.append(line)
|
||||
if section and not drop:
|
||||
out.extend(section)
|
||||
with open(system_reg, 'w', encoding='utf-8') as f:
|
||||
f.writelines(out)
|
||||
self.logger.info("Removed Wine Mono: %d uninstaller section(s) + %d NDP section(s)", removed_mono, removed_ndp)
|
||||
except Exception as exc:
|
||||
self.logger.warning("Wine Mono registry removal failed (non-fatal): %s", exc)
|
||||
|
||||
def _cleanup_wine_processes(self):
|
||||
"""Clean up winetricks processes only during component installation."""
|
||||
try:
|
||||
|
||||
@@ -19,69 +19,6 @@ logger = logging.getLogger(__name__)
|
||||
class GameUtilsMixin:
|
||||
"""Mixin for game-related utility operations"""
|
||||
|
||||
# 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"""
|
||||
import os
|
||||
|
||||
@@ -110,21 +110,14 @@ class ProtonOperationsMixin:
|
||||
with open(config_path, 'r') as f:
|
||||
config_data = vdf.load(f)
|
||||
|
||||
# Navigate to the correct location in the VDF structure
|
||||
if 'Software' not in config_data:
|
||||
config_data['Software'] = {}
|
||||
if 'Valve' not in config_data['Software']:
|
||||
config_data['Software']['Valve'] = {}
|
||||
if 'Steam' not in config_data['Software']['Valve']:
|
||||
config_data['Software']['Valve']['Steam'] = {}
|
||||
|
||||
# Get or create CompatToolMapping
|
||||
if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']:
|
||||
config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {}
|
||||
# config.vdf root key is "InstallConfigStore"
|
||||
ics = config_data.setdefault('InstallConfigStore', {})
|
||||
sw = ics.setdefault('Software', {})
|
||||
valve = sw.setdefault('Valve', {})
|
||||
steam = valve.setdefault('Steam', {})
|
||||
ctm = steam.setdefault('CompatToolMapping', {})
|
||||
|
||||
# Set the Proton version for this AppID using Steam's expected format
|
||||
# Steam requires a dict with 'name', 'config', and 'priority' keys
|
||||
config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(appid)] = {
|
||||
ctm[str(appid)] = {
|
||||
'name': proton_version,
|
||||
'config': '',
|
||||
'priority': '250'
|
||||
@@ -148,7 +141,7 @@ class ProtonOperationsMixin:
|
||||
# Verify it was set correctly
|
||||
with open(config_path, 'r') as f:
|
||||
verify_data = vdf.load(f)
|
||||
compat_mapping = verify_data.get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}).get(str(appid))
|
||||
compat_mapping = verify_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}).get(str(appid))
|
||||
logger.debug(f"[DEBUG] Verification: AppID {appid} -> {compat_mapping}")
|
||||
|
||||
return True
|
||||
|
||||
@@ -133,6 +133,17 @@ class WorkflowMixin:
|
||||
"""
|
||||
logger.info("Starting proven working automated prefix creation workflow")
|
||||
|
||||
if download_dir is None:
|
||||
try:
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
ini_path = Path(modlist_install_dir) / 'ModOrganizer.ini'
|
||||
dl_str = PathHandler().get_download_directory_linux_path(ini_path)
|
||||
if dl_str:
|
||||
download_dir = Path(dl_str)
|
||||
logger.debug(f"Resolved download_dir from ini: {download_dir}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not resolve download_dir from ini: {e}")
|
||||
|
||||
try:
|
||||
conflict_result = self.handle_existing_shortcut_conflict(
|
||||
shortcut_name,
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Diagnostic bundle service - collects logs, system info, and prefix records
|
||||
into a tar.gz for support reporting.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def build_bundle(output_dir: Optional[Path] = None) -> Path:
|
||||
"""
|
||||
Collect logs, system info, and per-prefix component records into a tar.gz.
|
||||
Returns the path to the created bundle file.
|
||||
"""
|
||||
from jackify.shared.paths import get_jackify_logs_dir, get_jackify_data_dir
|
||||
from jackify import __version__
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = get_jackify_data_dir() / "DiagnosticBundles"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
bundle_name = f"jackify_diagnostic_{timestamp}.tar.gz"
|
||||
bundle_path = output_dir / bundle_name
|
||||
|
||||
with tempfile.TemporaryDirectory() as staging_dir:
|
||||
staging = Path(staging_dir)
|
||||
|
||||
# System info
|
||||
_write_text(staging / "system_info.txt", _collect_system_info(__version__))
|
||||
|
||||
# Logs
|
||||
logs_dir = get_jackify_logs_dir()
|
||||
log_staging = staging / "logs"
|
||||
log_staging.mkdir()
|
||||
cutoff = datetime.now().timestamp() - timedelta(days=7).total_seconds()
|
||||
if logs_dir.is_dir():
|
||||
for log_file in sorted(logs_dir.glob("*.log*")):
|
||||
if log_file.is_file() and log_file.stat().st_mtime >= cutoff:
|
||||
try:
|
||||
shutil.copy2(log_file, log_staging / log_file.name)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not copy log %s: %s", log_file.name, exc)
|
||||
|
||||
# Config files (credentials scrubbed)
|
||||
_collect_config_files(staging)
|
||||
|
||||
# Per-prefix component records
|
||||
_collect_component_records(staging)
|
||||
|
||||
# Modlist shortcut info
|
||||
_collect_modlist_info(staging)
|
||||
|
||||
with tarfile.open(bundle_path, "w:gz") as tar:
|
||||
tar.add(staging_dir, arcname="jackify_diagnostic")
|
||||
|
||||
logger.info("Diagnostic bundle written: %s", bundle_path)
|
||||
return bundle_path
|
||||
|
||||
|
||||
def _collect_system_info(version: str) -> str:
|
||||
lines = [
|
||||
f"Jackify version: {version}",
|
||||
f"Date: {datetime.now().isoformat()}",
|
||||
f"Kernel: {platform.release()}",
|
||||
f"Machine: {platform.machine()}",
|
||||
"",
|
||||
]
|
||||
|
||||
_append_engine_info(lines)
|
||||
|
||||
# Distro
|
||||
try:
|
||||
import distro
|
||||
lines.append(f"Distro: {distro.name(pretty=True)}")
|
||||
except ImportError:
|
||||
try:
|
||||
lines.append(f"Distro: {platform.freedesktop_os_release().get('PRETTY_NAME', 'unknown')}")
|
||||
except Exception:
|
||||
lines.append("Distro: unknown")
|
||||
|
||||
# glibc
|
||||
try:
|
||||
glibc = platform.libc_ver()
|
||||
lines.append(f"glibc: {glibc[0]} {glibc[1]}")
|
||||
except Exception:
|
||||
lines.append("glibc: unknown")
|
||||
|
||||
# GPU
|
||||
try:
|
||||
gpu_out = subprocess.check_output(
|
||||
["lspci", "-mm"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
text=True,
|
||||
)
|
||||
gpu_lines = [l for l in gpu_out.splitlines() if "VGA" in l or "3D" in l or "Display" in l]
|
||||
for gl in gpu_lines[:2]:
|
||||
lines.append(f"GPU: {gl.strip()}")
|
||||
except Exception:
|
||||
lines.append("GPU: unavailable (lspci not found)")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Steam type
|
||||
_append_steam_info(lines)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _append_steam_info(lines: list) -> None:
|
||||
flatpak_steam = Path.home() / ".var/app/com.valvesoftware.Steam"
|
||||
native_steam = Path.home() / ".local/share/Steam"
|
||||
|
||||
if flatpak_steam.is_dir():
|
||||
lines.append("Steam: Flatpak")
|
||||
elif native_steam.is_dir():
|
||||
lines.append("Steam: Native")
|
||||
else:
|
||||
lines.append("Steam: not detected")
|
||||
|
||||
# Proton versions — official builds in steamapps/common, community builds in compatibilitytools.d
|
||||
proton_scan = [
|
||||
(native_steam / "steamapps/common", "valve"),
|
||||
(flatpak_steam / "data/Steam/steamapps/common", "valve"),
|
||||
(native_steam / "compatibilitytools.d", "community"),
|
||||
(flatpak_steam / "data/Steam/compatibilitytools.d", "community"),
|
||||
(Path.home() / ".steam/root/compatibilitytools.d", "community"),
|
||||
]
|
||||
proton_versions = []
|
||||
seen = set()
|
||||
for root, source in proton_scan:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for entry in sorted(root.iterdir()):
|
||||
if entry.is_dir() and entry.name not in seen and "proton" in entry.name.lower():
|
||||
seen.add(entry.name)
|
||||
proton_versions.append((entry.name, source))
|
||||
|
||||
if proton_versions:
|
||||
lines.append("Proton versions:")
|
||||
for name, source in sorted(proton_versions):
|
||||
lines.append(f" {name} ({source})")
|
||||
else:
|
||||
lines.append("Proton versions: none found")
|
||||
|
||||
|
||||
def _append_engine_info(lines: list) -> None:
|
||||
try:
|
||||
from jackify.backend.services.tool_registry import get_active_engine_id, ENGINE_TOOL_IDS, _read_manifest
|
||||
active = get_active_engine_id()
|
||||
lines.append(f"Active engine: {active}")
|
||||
for tool_id in ENGINE_TOOL_IDS:
|
||||
try:
|
||||
manifest = _read_manifest(tool_id)
|
||||
installed_version = manifest.get("installed_version")
|
||||
if installed_version:
|
||||
lines.append(f" {tool_id}: {installed_version}")
|
||||
else:
|
||||
lines.append(f" {tool_id}: not installed")
|
||||
except Exception:
|
||||
lines.append(f" {tool_id}: unknown")
|
||||
except Exception as e:
|
||||
lines.append(f"Engine info: unavailable ({e})")
|
||||
lines.append("")
|
||||
|
||||
|
||||
_CREDENTIAL_KEYS = {"nexus_api_key", "api_key", "access_token", "refresh_token", "token"}
|
||||
_EXCLUDED_CONFIG_FILES = {"nexus-oauth.json"}
|
||||
|
||||
|
||||
def _collect_config_files(staging: Path) -> None:
|
||||
"""Copy ~/.config/jackify files, excluding credential files and scrubbing credential fields."""
|
||||
config_dir = Path.home() / ".config" / "jackify"
|
||||
if not config_dir.is_dir():
|
||||
return
|
||||
|
||||
cfg_staging = staging / "config"
|
||||
cfg_staging.mkdir()
|
||||
|
||||
for cfg_file in sorted(config_dir.iterdir()):
|
||||
if not cfg_file.is_file():
|
||||
continue
|
||||
if cfg_file.name in _EXCLUDED_CONFIG_FILES:
|
||||
continue
|
||||
if cfg_file.suffix == ".json":
|
||||
try:
|
||||
data = json.loads(cfg_file.read_text(encoding="utf-8"))
|
||||
_scrub_credentials(data)
|
||||
(cfg_staging / cfg_file.name).write_text(
|
||||
json.dumps(data, indent=2), encoding="utf-8"
|
||||
)
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.debug("Could not parse %s for scrubbing: %s", cfg_file.name, exc)
|
||||
try:
|
||||
shutil.copy2(cfg_file, cfg_staging / cfg_file.name)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not copy config file %s: %s", cfg_file.name, exc)
|
||||
|
||||
|
||||
def _scrub_credentials(obj: object) -> None:
|
||||
"""Recursively replace credential field values with '[REDACTED]' in-place."""
|
||||
if isinstance(obj, dict):
|
||||
for key in list(obj.keys()):
|
||||
if key.startswith("nexus_premium_cache_"):
|
||||
del obj[key]
|
||||
elif any(cred in key.lower() for cred in _CREDENTIAL_KEYS):
|
||||
if obj[key] is not None:
|
||||
obj[key] = "[REDACTED]"
|
||||
else:
|
||||
_scrub_credentials(obj[key])
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_scrub_credentials(item)
|
||||
|
||||
|
||||
def _collect_component_records(staging: Path) -> None:
|
||||
"""Find jackify_components.json files in known prefix locations and copy them."""
|
||||
steam_compat = Path.home() / ".steam/root/steamapps/compatdata"
|
||||
flatpak_compat = Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/compatdata"
|
||||
|
||||
cutoff = datetime.now().timestamp() - timedelta(days=30).total_seconds()
|
||||
found = []
|
||||
for base in (steam_compat, flatpak_compat):
|
||||
if not base.is_dir():
|
||||
continue
|
||||
try:
|
||||
for pfx_dir in base.iterdir():
|
||||
record = pfx_dir / "pfx" / "jackify_components.json"
|
||||
if record.is_file() and record.stat().st_mtime >= cutoff:
|
||||
found.append((pfx_dir.name, record))
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
if not found:
|
||||
return
|
||||
|
||||
comp_staging = staging / "component_records"
|
||||
comp_staging.mkdir()
|
||||
for appid, record_path in found:
|
||||
dest = comp_staging / f"jackify_components_{appid}.json"
|
||||
try:
|
||||
shutil.copy2(record_path, dest)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not copy component record for %s: %s", appid, exc)
|
||||
|
||||
|
||||
def _collect_modlist_info(staging: Path) -> None:
|
||||
"""Collect installed modlist details from Steam shortcuts.vdf and config.vdf."""
|
||||
try:
|
||||
from jackify.backend.services.install_verifier_service import _load_verifier
|
||||
vmod = _load_verifier()
|
||||
modlists = vmod.discover_installed_modlists()
|
||||
except Exception as exc:
|
||||
logger.debug("Could not discover modlists for bundle: %s", exc)
|
||||
return
|
||||
|
||||
if not modlists:
|
||||
return
|
||||
|
||||
# Build a lookup of launch options keyed by unsigned appid from shortcuts.vdf
|
||||
launch_opts: dict = {}
|
||||
try:
|
||||
for vdf_path in vmod._find_shortcuts_vdf_paths():
|
||||
for sc in vmod._parse_shortcuts_vdf(vdf_path):
|
||||
raw = sc.get("appid", sc.get("AppID", sc.get("appId")))
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
unsigned = str(vmod._signed_to_unsigned(int(raw)))
|
||||
except Exception:
|
||||
continue
|
||||
lo = sc.get("LaunchOptions", sc.get("launchoptions", ""))
|
||||
if lo:
|
||||
launch_opts[unsigned] = lo
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read launch options from shortcuts.vdf: %s", exc)
|
||||
|
||||
# Read config.vdf once for Proton mappings
|
||||
proton_map: dict = {}
|
||||
try:
|
||||
for root in vmod._find_steam_roots():
|
||||
cfg = root / "config" / "config.vdf"
|
||||
if cfg.is_file():
|
||||
content = cfg.read_text(encoding="utf-8", errors="replace")
|
||||
for m in modlists:
|
||||
appid = m.get("appid", "")
|
||||
if appid and appid not in proton_map:
|
||||
tool = vmod._vdf_extract_compat_tool(content, appid)
|
||||
if tool:
|
||||
proton_map[appid] = tool
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read Proton versions from config.vdf: %s", exc)
|
||||
|
||||
records = []
|
||||
for m in modlists:
|
||||
appid = m.get("appid", "")
|
||||
records.append({
|
||||
"name": m.get("name", "Unknown"),
|
||||
"appid": appid,
|
||||
"install_dir": str(m.get("modlist_dir", "")),
|
||||
"game_type": m.get("game_type", "unknown"),
|
||||
"proton_version": proton_map.get(appid),
|
||||
"launch_options": launch_opts.get(appid),
|
||||
})
|
||||
|
||||
_write_text(staging / "modlists.json", json.dumps(records, indent=2))
|
||||
|
||||
|
||||
def _write_text(path: Path, content: str) -> None:
|
||||
try:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
except Exception as exc:
|
||||
logger.debug("Could not write %s: %s", path, exc)
|
||||
@@ -3,13 +3,12 @@ Watches a directory for newly downloaded files and matches them against a
|
||||
list of pending manual download items by lax filename comparison.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from threading import Thread, Event
|
||||
from threading import Thread, Event, Lock
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,6 +28,11 @@ class DownloadWatcherService:
|
||||
Caller sets pending_items (list of dicts with at least 'file_name') and
|
||||
registers an on_candidate callback that receives (Path, dict) when a
|
||||
potential match is detected (after debounce, before hash validation).
|
||||
|
||||
Detection strategy: every scan checks every non-temp file against pending
|
||||
items. Files currently being debounced are skipped to avoid duplicate
|
||||
threads. When debounce completes (pass or fail), the path is cleared from
|
||||
the in-flight set so the next scan can re-detect it if still pending.
|
||||
"""
|
||||
|
||||
def __init__(self, config: WatcherConfig, on_candidate: Callable[[Path, dict], None]):
|
||||
@@ -38,8 +42,8 @@ class DownloadWatcherService:
|
||||
self._pending_exact: list[tuple[str, dict]] = []
|
||||
self._stop_event = Event()
|
||||
self._thread: Optional[Thread] = None
|
||||
# Track known files so we only react to new/changed ones
|
||||
self._known: dict[Path, float] = {}
|
||||
self._debouncing: set[Path] = set()
|
||||
self._debouncing_lock = Lock()
|
||||
|
||||
def set_pending_items(self, items: list[dict]) -> None:
|
||||
"""Replace the pending items list. Thread-safe for simple list swap."""
|
||||
@@ -77,17 +81,11 @@ class DownloadWatcherService:
|
||||
for path in entries:
|
||||
if not path.is_file():
|
||||
continue
|
||||
# Skip browser temp files
|
||||
if path.suffix in ('.part', '.crdownload', '.tmp'):
|
||||
continue
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
prev_mtime = self._known.get(path)
|
||||
if prev_mtime == mtime:
|
||||
continue
|
||||
self._known[path] = mtime
|
||||
with self._debouncing_lock:
|
||||
if path in self._debouncing:
|
||||
continue
|
||||
self._check_candidate(path)
|
||||
except OSError as e:
|
||||
logger.debug(f"Watcher scan error on {watch_dir}: {e}")
|
||||
@@ -100,15 +98,14 @@ class DownloadWatcherService:
|
||||
logger.debug(f"Candidate exact match: {path.name}")
|
||||
self._debounce_and_emit(path, item)
|
||||
return
|
||||
# Some modlist metadata stores filenames with a leading dot that browsers
|
||||
# strip when saving the download. Match against the stripped expected name.
|
||||
# Leading-dot normalisation: browsers strip a leading dot from filenames.
|
||||
for expected_name, item in self._pending_exact:
|
||||
if expected_name.lstrip('.') == candidate_name:
|
||||
logger.debug(f"Candidate dot-normalized match: {path.name} -> {expected_name}")
|
||||
self._debounce_and_emit(path, item)
|
||||
return
|
||||
# Some modlist metadata stores filenames with a leading numeric prefix
|
||||
# (e.g. "1_filename.zip") that is absent from the browser-saved file.
|
||||
# Numeric-prefix normalisation: engine metadata may include a leading
|
||||
# numeric prefix (e.g. "1_filename.zip") absent from the downloaded file.
|
||||
for expected_name, item in self._pending_exact:
|
||||
stripped = re.sub(r'^\d+_', '', expected_name)
|
||||
if stripped != expected_name and stripped == candidate_name:
|
||||
@@ -117,30 +114,64 @@ class DownloadWatcherService:
|
||||
return
|
||||
|
||||
def _debounce_and_emit(self, path: Path, item: dict) -> None:
|
||||
with self._debouncing_lock:
|
||||
self._debouncing.add(path)
|
||||
|
||||
expected_size = 0
|
||||
try:
|
||||
expected_size = int(item.get('expected_size', 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
expected_size = 0
|
||||
|
||||
def _wait_and_emit():
|
||||
prev_size = -1
|
||||
stable_count = 0
|
||||
needed = max(1, int(self._config.debounce_seconds / 0.5))
|
||||
for _ in range(needed * 4): # max ~2× debounce time
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
return
|
||||
if size == prev_size:
|
||||
stable_count += 1
|
||||
if stable_count >= needed:
|
||||
break
|
||||
else:
|
||||
stable_count = 0
|
||||
prev_size = size
|
||||
if path.exists():
|
||||
self._on_candidate(path, item)
|
||||
became_stable = False
|
||||
try:
|
||||
prev_size = -1
|
||||
stable_count = 0
|
||||
needed = max(1, int(self._config.debounce_seconds / 0.5))
|
||||
for _ in range(needed * 4):
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
return
|
||||
# A slow/in-progress download can hold a constant size for the
|
||||
# debounce window (initial throttle, network stall) and look
|
||||
# stable while still incomplete. Validating it prematurely fails
|
||||
# the hash, reverts the item to pending, and triggers a duplicate
|
||||
# browser tab. Hold off until the file reaches its known size.
|
||||
if expected_size > 0 and size != expected_size:
|
||||
stable_count = 0
|
||||
prev_size = size
|
||||
continue
|
||||
if size == prev_size:
|
||||
stable_count += 1
|
||||
if stable_count >= needed:
|
||||
became_stable = True
|
||||
break
|
||||
else:
|
||||
stable_count = 0
|
||||
prev_size = size
|
||||
# Only validate if the file stopped growing. If still downloading,
|
||||
# release the debounce lock so the next scan can retry once it finishes.
|
||||
if became_stable and path.exists():
|
||||
self._on_candidate(path, item)
|
||||
# Path stays in _debouncing until release_path() is called by the
|
||||
# manager after validation completes, preventing repeated re-fires.
|
||||
finally:
|
||||
if not became_stable:
|
||||
with self._debouncing_lock:
|
||||
self._debouncing.discard(path)
|
||||
|
||||
Thread(target=_wait_and_emit, daemon=True, name=f'Debounce-{path.name[:20]}').start()
|
||||
|
||||
def release_path(self, path: Path) -> None:
|
||||
"""Allow the watcher to re-detect a path after validation completes."""
|
||||
with self._debouncing_lock:
|
||||
self._debouncing.discard(path)
|
||||
|
||||
def _watch_loop(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
self._scan()
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Engine Invoker
|
||||
|
||||
Resolves the active install engine and builds the appropriate subprocess command.
|
||||
Keeps engine-specific CLI differences isolated from install workflow code.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_active_engine_id() -> str:
|
||||
from jackify.backend.services.tool_registry import get_active_engine_id as _get
|
||||
return _get()
|
||||
|
||||
|
||||
def is_clf3_active() -> bool:
|
||||
return get_active_engine_id() == "clf3"
|
||||
|
||||
|
||||
def ensure_engine_available(engine_id: str = "jackify-engine") -> Tuple[bool, str]:
|
||||
"""
|
||||
Check that the given engine binary is present and download it if not.
|
||||
Returns (True, path) on success, (False, error_message) on failure.
|
||||
Call this at startup before the first install attempt.
|
||||
"""
|
||||
from jackify.backend.services.tool_registry import ToolRegistry
|
||||
path = get_engine_path(engine_id)
|
||||
if path:
|
||||
return True, path
|
||||
logger.info("Engine %s not found, attempting download via Tools Hub", engine_id)
|
||||
ok, msg = ToolRegistry().install(engine_id)
|
||||
if not ok:
|
||||
return False, msg
|
||||
path = get_engine_path(engine_id)
|
||||
if not path:
|
||||
return False, f"{engine_id} downloaded but binary not found after install"
|
||||
return True, path
|
||||
|
||||
|
||||
def get_engine_path(engine_id: str) -> Optional[str]:
|
||||
"""Return the filesystem path to the engine binary for the given engine_id."""
|
||||
from jackify.backend.services.tool_registry import ToolRegistry
|
||||
path = ToolRegistry().get_binary_path(engine_id)
|
||||
if path and path.is_file():
|
||||
return str(path)
|
||||
|
||||
if engine_id == "jackify-engine":
|
||||
from jackify.backend.core.modlist_operations import get_jackify_engine_path
|
||||
return get_jackify_engine_path()
|
||||
|
||||
logger.warning("Engine binary not found for engine_id=%s", engine_id)
|
||||
return None
|
||||
|
||||
|
||||
def get_active_engine_path() -> Optional[str]:
|
||||
"""Return the binary path for the currently active engine."""
|
||||
return get_engine_path(get_active_engine_id())
|
||||
|
||||
|
||||
def resolve_game_dir(game_type: Optional[str], modlist_path: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Resolve the vanilla game installation directory for CLF3's --game argument.
|
||||
Searches Steam and Heroic-managed stores in order.
|
||||
Returns None if the path cannot be determined.
|
||||
Use resolve_game_location() when the store identity is also needed.
|
||||
"""
|
||||
result = resolve_game_location(game_type)
|
||||
return result[0] if result else None
|
||||
|
||||
|
||||
def resolve_game_location(game_type: Optional[str]) -> Optional[tuple]:
|
||||
"""
|
||||
Return (path_str, store) for the detected game installation, or None.
|
||||
store is one of: 'steam', 'gog', 'epic', 'unknown'.
|
||||
"""
|
||||
if not game_type:
|
||||
return None
|
||||
try:
|
||||
from jackify.backend.handlers.vanilla_game_finder import VanillaGameFinder
|
||||
result = VanillaGameFinder().find(game_type)
|
||||
if result:
|
||||
path, store = result
|
||||
return str(path), store
|
||||
except Exception as e:
|
||||
logger.debug("Game dir detection failed for %s: %s", game_type, e)
|
||||
return None
|
||||
|
||||
|
||||
def build_install_command(
|
||||
engine_id: str,
|
||||
engine_path: str,
|
||||
wabbajack: str,
|
||||
install_dir: str,
|
||||
downloads_dir: str,
|
||||
game_dir: Optional[str] = None,
|
||||
install_mode: str = "online",
|
||||
debug: bool = False,
|
||||
) -> List[str]:
|
||||
"""Build the subprocess install command for the given engine."""
|
||||
if engine_id == "clf3":
|
||||
return _build_clf3_command(engine_path, wabbajack, install_dir, downloads_dir, game_dir)
|
||||
return _build_jackify_engine_command(engine_path, wabbajack, install_dir, downloads_dir, install_mode, debug, game_dir)
|
||||
|
||||
|
||||
def _build_jackify_engine_command(
|
||||
engine_path: str,
|
||||
wabbajack: str,
|
||||
install_dir: str,
|
||||
downloads_dir: str,
|
||||
install_mode: str,
|
||||
debug: bool,
|
||||
game_dir: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
cmd = [engine_path, "install", "--show-file-progress"]
|
||||
if wabbajack.endswith(".wabbajack") and os.path.isfile(wabbajack):
|
||||
cmd += ["-w", wabbajack]
|
||||
else:
|
||||
cmd += ["-m", wabbajack]
|
||||
cmd += ["-o", install_dir, "-d", downloads_dir]
|
||||
if game_dir:
|
||||
cmd += ["-g", game_dir]
|
||||
if debug:
|
||||
cmd.append("--debug")
|
||||
return cmd
|
||||
|
||||
|
||||
def _read_resource_settings() -> dict:
|
||||
"""Read resource_settings.json from the Jackify config dir; return empty dict on any failure."""
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_config_dir
|
||||
import json
|
||||
path = get_jackify_config_dir() / "resource_settings.json"
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _clf3_default_workers() -> int:
|
||||
"""Default worker count matching jackify-engine: full cpu_count."""
|
||||
import multiprocessing
|
||||
return max(1, multiprocessing.cpu_count() or 4)
|
||||
|
||||
|
||||
def _build_clf3_command(
|
||||
engine_path: str,
|
||||
wabbajack: str,
|
||||
install_dir: str,
|
||||
downloads_dir: str,
|
||||
game_dir: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
# Positional order: <WABBAJACK_FILE> <DOWNLOADS> <OUTPUT>
|
||||
# CLF3 performs its own game detection (Steam + Heroic) with file verification.
|
||||
# game_dir is reserved for explicit edge-case overrides only.
|
||||
cmd = [engine_path, "install", "--jackify"]
|
||||
if os.environ.get("JACKIFY_CLF3_VERBOSE"):
|
||||
cmd.append("--verbose")
|
||||
logger.info("CLF3 verbose mode enabled (JACKIFY_CLF3_VERBOSE)")
|
||||
if game_dir:
|
||||
cmd += ["--game", game_dir]
|
||||
|
||||
res = _read_resource_settings()
|
||||
default = _clf3_default_workers()
|
||||
|
||||
def _tasks(key: str) -> int:
|
||||
val = res.get(key, {}).get("MaxTasks", 0)
|
||||
return val if val > 0 else default
|
||||
|
||||
concurrent = _tasks("Downloads")
|
||||
install_workers = _tasks("Installer")
|
||||
sevenzip_workers = _tasks("File Extractor")
|
||||
|
||||
cmd += [
|
||||
"--concurrent", str(concurrent),
|
||||
"--install-workers", str(install_workers),
|
||||
"--sevenzip-workers", str(sevenzip_workers),
|
||||
]
|
||||
logger.debug(
|
||||
"CLF3 resource flags: concurrent=%d install_workers=%d sevenzip_workers=%d (from %s)",
|
||||
concurrent, install_workers, sevenzip_workers,
|
||||
"resource_settings.json" if res else "default (cpu_count)",
|
||||
)
|
||||
|
||||
cmd += [wabbajack, downloads_dir, install_dir]
|
||||
return cmd
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Service for running verify_install.py from Jackify workflows."""
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BUNDLED_PATH = Path(__file__).parent.parent.parent / "tools" / "verify_install.py"
|
||||
|
||||
|
||||
def _load_verifier():
|
||||
spec = importlib.util.spec_from_file_location("_verify_install_bundled", _BUNDLED_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Cannot locate bundled verify_install.py at {_BUNDLED_PATH}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules.setdefault("_verify_install_bundled", module)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def resolve_pfx_for_appid(appid: str) -> Optional[Path]:
|
||||
"""Resolve the Proton prefix path for a Steam AppID."""
|
||||
if not appid:
|
||||
return None
|
||||
steam_roots = [
|
||||
Path.home() / ".steam" / "steam",
|
||||
Path.home() / ".local" / "share" / "Steam",
|
||||
Path.home() / ".steam" / "root",
|
||||
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam",
|
||||
]
|
||||
for root in steam_roots:
|
||||
pfx = root / "steamapps" / "compatdata" / str(appid) / "pfx"
|
||||
if pfx.is_dir():
|
||||
return pfx
|
||||
return None
|
||||
|
||||
|
||||
def run_install_verification(pfx: Path, modlist_dir: Path, game_type: str, appid: str = "", modlist_name: str = ""):
|
||||
"""Run the install verifier and return a Results object, or None on failure."""
|
||||
try:
|
||||
verifier = _load_verifier()
|
||||
return verifier.run_verification(pfx, modlist_dir, game_type, appid, modlist_name)
|
||||
except Exception as e:
|
||||
logger.warning("Install verifier failed: %s", e, exc_info=True)
|
||||
raise
|
||||
@@ -119,6 +119,7 @@ class ManualDownloadManager(ManualDownloadManagerApiMixin, ManualDownloadManager
|
||||
self._startup_precheck_pending = 0
|
||||
self._run_id = f"mdl-{int(time.time())}-{id(self) % 10000}"
|
||||
self._last_progress_log_completed = -1
|
||||
self._last_browser_open: dict[str, float] = {} # file_name -> monotonic timestamp
|
||||
|
||||
additional = [modlist_download_dir] if modlist_download_dir != watch_directory else []
|
||||
config = WatcherConfig(watch_directory=watch_directory, additional_dirs=additional)
|
||||
|
||||
@@ -123,8 +123,12 @@ class ManualDownloadManagerApiMixin:
|
||||
with self._lock:
|
||||
for item in self._items:
|
||||
if item.file_name == file_name and item.status not in ('complete',):
|
||||
# Only free the browser slot if this item actually held one.
|
||||
# 'pending' items have no slot; 'validating' items still hold
|
||||
# the slot counted when they entered 'browser_opened'.
|
||||
had_slot = item.status in ('browser_opened', 'validating')
|
||||
item.status = 'deferred'
|
||||
if self._active_tabs > 0:
|
||||
if had_slot and self._active_tabs > 0:
|
||||
self._active_tabs -= 1
|
||||
item_to_notify = item
|
||||
break
|
||||
@@ -133,6 +137,11 @@ class ManualDownloadManagerApiMixin:
|
||||
self._open_next_tabs()
|
||||
self._check_all_done()
|
||||
|
||||
def force_rescan(self) -> None:
|
||||
"""Re-ingest existing files immediately (Scan Now button)."""
|
||||
self._diag("MDL-1026", "Force rescan requested by user")
|
||||
self._ingest_existing_files()
|
||||
|
||||
def set_concurrent_limit(self, limit: int) -> None:
|
||||
with self._lock:
|
||||
self._limit = max(1, min(5, limit))
|
||||
|
||||
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -84,6 +86,8 @@ class ManualDownloadManagerRuntimeMixin:
|
||||
return item
|
||||
return None
|
||||
|
||||
_BROWSER_OPEN_COOLDOWN = 30.0 # seconds before the same file's URL may be re-opened
|
||||
|
||||
def _open_browser(self, item: DownloadItem) -> tuple[bool, Optional[str]]:
|
||||
url = item.nexus_url
|
||||
if not url:
|
||||
@@ -91,6 +95,16 @@ class ManualDownloadManagerRuntimeMixin:
|
||||
logger.warning(f"{msg}: {item.file_name}")
|
||||
return False, msg
|
||||
|
||||
now = time.monotonic()
|
||||
last = self._last_browser_open.get(item.file_name, 0.0)
|
||||
if now - last < self._BROWSER_OPEN_COOLDOWN:
|
||||
remaining = int(self._BROWSER_OPEN_COOLDOWN - (now - last))
|
||||
logger.warning(
|
||||
f"Suppressed duplicate browser open for {item.file_name} "
|
||||
f"(cooldown: {remaining}s remaining)"
|
||||
)
|
||||
return True, None
|
||||
|
||||
# Linux desktop launch fallbacks. xdg-open should cover most environments,
|
||||
# but keep alternates for distributions where handlers differ.
|
||||
launch_cmds = (
|
||||
@@ -99,6 +113,14 @@ class ManualDownloadManagerRuntimeMixin:
|
||||
['sensible-browser', url],
|
||||
)
|
||||
|
||||
# Strip AppImage library path overrides before launching external processes.
|
||||
# Inheriting LD_LIBRARY_PATH causes xdg-open/kde-open to load bundled Qt
|
||||
# libs, which produces symbol version mismatches on distributions that ship
|
||||
# a different Qt version than the one bundled in the AppImage.
|
||||
clean_env = os.environ.copy()
|
||||
for var in ('LD_LIBRARY_PATH', 'LD_PRELOAD'):
|
||||
clean_env.pop(var, None)
|
||||
|
||||
launch_errors: list[str] = []
|
||||
for cmd in launch_cmds:
|
||||
try:
|
||||
@@ -107,6 +129,7 @@ class ManualDownloadManagerRuntimeMixin:
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
env=clean_env,
|
||||
)
|
||||
except OSError as e:
|
||||
launch_errors.append(f"{cmd[0]} not available: {e}")
|
||||
@@ -116,10 +139,12 @@ class ManualDownloadManagerRuntimeMixin:
|
||||
rc = proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Launcher still running after handoff window; treat as success.
|
||||
self._last_browser_open[item.file_name] = time.monotonic()
|
||||
logger.debug(f"Opened browser for: {item.file_name} via {cmd[0]}")
|
||||
return True, None
|
||||
|
||||
if rc == 0:
|
||||
self._last_browser_open[item.file_name] = time.monotonic()
|
||||
logger.debug(f"Opened browser for: {item.file_name} via {cmd[0]}")
|
||||
return True, None
|
||||
|
||||
@@ -144,7 +169,7 @@ class ManualDownloadManagerRuntimeMixin:
|
||||
item = self._item_by_name(file_name)
|
||||
if item is None:
|
||||
reject_reason = "unknown_item"
|
||||
elif item.status in ('complete', 'skipped'):
|
||||
elif item.status in ('complete', 'skipped', 'deferred'):
|
||||
reject_reason = f"terminal_status:{item.status}"
|
||||
elif item.status == 'validating':
|
||||
reject_reason = "already_validating"
|
||||
@@ -224,23 +249,30 @@ class ManualDownloadManagerRuntimeMixin:
|
||||
item_to_notify = item
|
||||
completed_now = True
|
||||
else:
|
||||
# Hash mismatch or validation error - revert to pending so the
|
||||
# sliding window can re-open a browser tab and the watcher can
|
||||
# re-validate if the user downloads the correct file.
|
||||
item.status = 'pending'
|
||||
msg = result.error or f"Hash mismatch (got {result.computed_hash})"
|
||||
item.error_message = msg
|
||||
logger.warning(f"Validation failed for {file_name}: {msg}")
|
||||
if had_browser_slot and self._active_tabs > 0:
|
||||
self._active_tabs -= 1
|
||||
item_to_notify = item
|
||||
validation_failed = True
|
||||
# If the user deferred this item while validation was in-flight,
|
||||
# skip_item already decremented _active_tabs and set status='deferred'.
|
||||
# Preserve the defer; don't double-decrement or re-open a browser tab.
|
||||
if item.status == 'deferred':
|
||||
item_to_notify = item
|
||||
validation_failed = True
|
||||
else:
|
||||
# Revert to pending so the sliding window can re-open a browser tab.
|
||||
item.status = 'pending'
|
||||
item.error_message = msg
|
||||
logger.warning(f"Validation failed for {file_name}: {msg}")
|
||||
if had_browser_slot and self._active_tabs > 0:
|
||||
self._active_tabs -= 1
|
||||
item_to_notify = item
|
||||
validation_failed = True
|
||||
if from_startup_precheck and self._startup_precheck_pending > 0:
|
||||
self._startup_precheck_pending -= 1
|
||||
precheck_ready = self._startup_precheck_pending == 0
|
||||
|
||||
if item_to_notify is not None:
|
||||
self._notify(item_to_notify)
|
||||
if result.file_path:
|
||||
self._watcher.release_path(result.file_path)
|
||||
if completed_now:
|
||||
self._diag(
|
||||
"MDL-1021",
|
||||
|
||||
@@ -158,9 +158,8 @@ class ModlistService(ModlistServiceInstallationMixin):
|
||||
logger.error(f"Failed to list modlists: {e}")
|
||||
raise
|
||||
|
||||
def configure_modlist_post_steam(self, context: ModlistContext,
|
||||
def configure_modlist_post_steam(self, context: ModlistContext,
|
||||
progress_callback=None,
|
||||
manual_steps_callback=None,
|
||||
completion_callback=None) -> bool:
|
||||
"""Configure a modlist after Steam setup is complete.
|
||||
|
||||
@@ -173,7 +172,6 @@ class ModlistService(ModlistServiceInstallationMixin):
|
||||
Args:
|
||||
context: Modlist context with updated app_id
|
||||
progress_callback: Optional callback for progress updates
|
||||
manual_steps_callback: Called when manual steps needed
|
||||
completion_callback: Called when configuration is complete
|
||||
|
||||
Returns:
|
||||
@@ -253,12 +251,12 @@ class ModlistService(ModlistServiceInstallationMixin):
|
||||
'path': str(context.install_dir),
|
||||
'mo2_exe_path': str(context.install_dir / 'ModOrganizer.exe'),
|
||||
'resolution': getattr(context, 'resolution', None),
|
||||
'skip_confirmation': True, # Service layer should be non-interactive
|
||||
'manual_steps_completed': True, # Manual steps were done in GUI
|
||||
'appid': getattr(context, 'app_id', None), # Use updated app_id from Steam
|
||||
'skip_confirmation': True,
|
||||
'appid': getattr(context, 'app_id', None),
|
||||
'engine_installed': getattr(context, 'engine_installed', False), # Path manipulation flag
|
||||
'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None,
|
||||
'modlist_source': getattr(context, 'modlist_source', None),
|
||||
'suppress_completion_banner': True,
|
||||
}
|
||||
|
||||
debug_callback(f"Configuration context built: {config_context}")
|
||||
@@ -317,7 +315,11 @@ class ModlistService(ModlistServiceInstallationMixin):
|
||||
debug_callback("Calling run_modlist_configuration_phase")
|
||||
success = modlist_menu.run_modlist_configuration_phase(config_context)
|
||||
debug_callback(f"Configuration phase result: {success}")
|
||||
|
||||
context.steam_restart_needed = config_context.get('steam_restart_needed', False)
|
||||
context.mounts_app_name = config_context.get('mounts_app_name', '')
|
||||
context.mounts_exe_path = config_context.get('mounts_exe_path', '')
|
||||
context.mounts_dl_path = config_context.get('mounts_dl_path', '')
|
||||
|
||||
# Restore stdout before ENB detection and completion callback
|
||||
if original_stdout:
|
||||
sys.stdout = original_stdout
|
||||
@@ -405,20 +407,18 @@ class ModlistService(ModlistServiceInstallationMixin):
|
||||
|
||||
return False
|
||||
|
||||
def configure_modlist(self, context: ModlistContext,
|
||||
progress_callback=None,
|
||||
manual_steps_callback=None,
|
||||
def configure_modlist(self, context: ModlistContext,
|
||||
progress_callback=None,
|
||||
completion_callback=None,
|
||||
output_callback=None) -> bool:
|
||||
"""Configure a modlist after installation.
|
||||
|
||||
|
||||
Args:
|
||||
context: Modlist context
|
||||
progress_callback: Optional callback for progress updates
|
||||
manual_steps_callback: Optional callback for manual steps
|
||||
completion_callback: Optional callback for completion
|
||||
output_callback: Optional callback for output/logging
|
||||
|
||||
|
||||
Returns:
|
||||
True if configuration successful, False otherwise
|
||||
"""
|
||||
@@ -438,15 +438,14 @@ class ModlistService(ModlistServiceInstallationMixin):
|
||||
'path': str(context.install_dir),
|
||||
'mo2_exe_path': str(context.install_dir / 'ModOrganizer.exe'),
|
||||
'resolution': getattr(context, 'resolution', None),
|
||||
'skip_confirmation': True, # Service layer should be non-interactive
|
||||
'manual_steps_completed': False,
|
||||
'appid': getattr(context, 'app_id', None), # Fix: Include appid like other configuration paths
|
||||
'skip_confirmation': True,
|
||||
'appid': getattr(context, 'app_id', None),
|
||||
'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None,
|
||||
}
|
||||
|
||||
# DEBUG: Log what resolution we're passing
|
||||
logger.info(f"DEBUG: config_context resolution = {config_context['resolution']}")
|
||||
logger.info(f"DEBUG: context.resolution = {getattr(context, 'resolution', 'NOT_SET')}")
|
||||
logger.info(f"config_context resolution = {config_context['resolution']}")
|
||||
logger.info(f"context.resolution = {getattr(context, 'resolution', 'NOT_SET')}")
|
||||
|
||||
# Run the complete configuration phase
|
||||
success = modlist_menu.run_modlist_configuration_phase(config_context)
|
||||
|
||||
@@ -300,13 +300,21 @@ class ModlistServiceInstallationMixin:
|
||||
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:
|
||||
_gt = context.get('game_type') or ''
|
||||
if 'fallout4' in _gt.lower():
|
||||
_ck_name = "Fallout 4 Creation Kit"
|
||||
_ck_search = "Fallout 4: Creation Kit"
|
||||
else:
|
||||
_ck_name = "Skyrim Special Edition Creation Kit"
|
||||
_ck_search = "Skyrim Special Edition: Creation Kit"
|
||||
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(f" This modlist requires the {_ck_name}.")
|
||||
output_callback(f" - In Steam, search for '{_ck_search}' 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.")
|
||||
if 'fallout4' not in _gt.lower():
|
||||
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
|
||||
|
||||
@@ -224,10 +224,19 @@ class NativeSteamService:
|
||||
try:
|
||||
# Create backup first
|
||||
if shortcuts_path.exists():
|
||||
backup_path = shortcuts_path.with_suffix(f".vdf.backup_{int(time.time())}")
|
||||
import shutil
|
||||
import glob
|
||||
backup_dir = shortcuts_path.parent / "backups"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
backup_path = backup_dir / f"shortcuts_{int(time.time())}.bak"
|
||||
shutil.copy2(shortcuts_path, backup_path)
|
||||
logger.info(f"Created backup: {backup_path}")
|
||||
existing = sorted(glob.glob(str(backup_dir / "shortcuts_*.bak")))
|
||||
for old in existing[:-5]:
|
||||
try:
|
||||
os.remove(old)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure parent directory exists
|
||||
shortcuts_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -386,10 +395,19 @@ class NativeSteamService:
|
||||
return False
|
||||
|
||||
# Create backup first
|
||||
backup_path = config_path.with_suffix(f".vdf.backup_{int(time.time())}")
|
||||
import shutil
|
||||
import glob
|
||||
backup_dir = config_path.parent / "backups"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
backup_path = backup_dir / f"config_{int(time.time())}.bak"
|
||||
shutil.copy2(config_path, backup_path)
|
||||
logger.info(f"Created backup: {backup_path}")
|
||||
existing = sorted(glob.glob(str(backup_dir / "config_*.bak")))
|
||||
for old in existing[:-5]:
|
||||
try:
|
||||
os.remove(old)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Read the file as text to avoid VDF library formatting issues
|
||||
with open(config_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
|
||||
@@ -212,7 +212,7 @@ class NexusAuthService:
|
||||
Returns:
|
||||
Tuple of (valid, username_or_error)
|
||||
"""
|
||||
return self.api_key_service.validate_api_key(api_key)
|
||||
return self.api_key_service.validate_api_key_works(api_key)
|
||||
|
||||
def ensure_valid_auth(self) -> Optional[str]:
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Nexus Premium status detection service."""
|
||||
import hashlib
|
||||
import time
|
||||
import logging
|
||||
from typing import Tuple, Optional
|
||||
@@ -70,7 +71,8 @@ class NexusPremiumService:
|
||||
|
||||
def _cache_key(self, token: str, is_oauth: bool = False) -> str:
|
||||
suffix = "oauth" if is_oauth else "apikey"
|
||||
return f"nexus_premium_cache_{token[:8]}_{suffix}"
|
||||
token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()[:12]
|
||||
return f"nexus_premium_cache_{token_hash}_{suffix}"
|
||||
|
||||
def _read_cache(self, token: str, is_oauth: bool = False) -> Optional[Tuple[bool, Optional[str]]]:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""NXM download pipeline: resolve CDN URL and save to modlist download directory."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from jackify.backend.services.nxm_url import NxmUrl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NEXUS_API_BASE = "https://api.nexusmods.com/v1"
|
||||
_CHUNK_SIZE = 65536
|
||||
|
||||
|
||||
def get_nxm_download_url(nxm: NxmUrl, auth_token: str, auth_method: str = "api_key") -> Optional[str]:
|
||||
"""Resolve an NXM URL to a CDN download URL using the Nexus API.
|
||||
|
||||
The key/expires from the NXM URL authorise the request for both Premium
|
||||
and non-Premium accounts.
|
||||
"""
|
||||
url = (
|
||||
f"{_NEXUS_API_BASE}/games/{nxm.game}/mods/{nxm.mod_id}"
|
||||
f"/files/{nxm.file_id}/download_link.json"
|
||||
)
|
||||
if auth_method == "oauth":
|
||||
headers = {"Authorization": f"Bearer {auth_token}", "User-Agent": "jackify"}
|
||||
else:
|
||||
headers = {"apikey": auth_token, "User-Agent": "jackify"}
|
||||
|
||||
params: dict = {}
|
||||
if nxm.key:
|
||||
params["key"] = nxm.key
|
||||
if nxm.expires:
|
||||
params["expires"] = nxm.expires
|
||||
|
||||
try:
|
||||
resp = requests.get(url, headers=headers, params=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if isinstance(data, list) and data:
|
||||
cdn_url = data[0].get("URI")
|
||||
logger.debug("Resolved NXM CDN URL for file %s", nxm.file_id)
|
||||
return cdn_url
|
||||
logger.warning("Nexus API returned empty download link list for file %s", nxm.file_id)
|
||||
return None
|
||||
except requests.HTTPError as e:
|
||||
logger.error(
|
||||
"Nexus API error resolving NXM URL (method=%s, status=%s): %s",
|
||||
auth_method, e.response.status_code if e.response else "?", e,
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error resolving NXM URL: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_mo2_download_dir(modlist_dir: Path) -> Optional[Path]:
|
||||
"""Read download_directory from ModOrganizer.ini and resolve to a Linux path.
|
||||
|
||||
Returns None if the directory is not configured or cannot be resolved.
|
||||
Delegates to PathHandler which handles all MO2 path formats correctly.
|
||||
"""
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
ini_path = modlist_dir / "ModOrganizer.ini"
|
||||
if not ini_path.exists():
|
||||
logger.warning("ModOrganizer.ini not found at %s", ini_path)
|
||||
return None
|
||||
dl_str = PathHandler().get_download_directory_linux_path(ini_path)
|
||||
if dl_str:
|
||||
return Path(dl_str)
|
||||
default = modlist_dir / "downloads"
|
||||
logger.debug("No download_directory in ini, using default: %s", default)
|
||||
return default
|
||||
|
||||
|
||||
def download_nxm_file(
|
||||
cdn_url: str,
|
||||
download_dir: Path,
|
||||
filename: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Download a file to the modlist download directory.
|
||||
|
||||
Returns (success, message).
|
||||
"""
|
||||
try:
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = download_dir / filename
|
||||
|
||||
resp = requests.get(cdn_url, stream=True, timeout=60)
|
||||
resp.raise_for_status()
|
||||
|
||||
total = int(resp.headers.get("content-length", 0))
|
||||
downloaded = 0
|
||||
|
||||
with open(dest, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress_callback and total > 0:
|
||||
progress_callback(downloaded, total)
|
||||
|
||||
logger.info("NXM download complete: %s (%d bytes)", dest.name, downloaded)
|
||||
return True, f"Saved to {dest}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error("NXM download failed: %s", e)
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def filename_from_cdn_url(cdn_url: str, fallback: str) -> str:
|
||||
"""Extract a filename from a CDN URL, falling back to provided name."""
|
||||
path = cdn_url.split("?")[0].rstrip("/")
|
||||
name = path.split("/")[-1]
|
||||
return name if name else fallback
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Unix socket IPC for single-instance NXM URL routing.
|
||||
|
||||
The running Jackify instance listens on a QLocalServer. A second instance
|
||||
launched by the OS protocol handler connects, sends the nxm:// URL, and exits.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SOCKET_NAME = "jackify-nxm-ipc"
|
||||
_CONNECT_TIMEOUT_MS = 1000
|
||||
|
||||
|
||||
class NxmIpcServer(QObject):
|
||||
"""Listens for nxm:// URLs from secondary Jackify instances."""
|
||||
|
||||
url_received = Signal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._server: Optional[QLocalServer] = None
|
||||
|
||||
def start(self) -> bool:
|
||||
QLocalServer.removeServer(_SOCKET_NAME)
|
||||
self._server = QLocalServer(self)
|
||||
self._server.newConnection.connect(self._on_connection)
|
||||
if not self._server.listen(_SOCKET_NAME):
|
||||
logger.warning("NXM IPC server failed to start: %s", self._server.errorString())
|
||||
return False
|
||||
logger.debug("NXM IPC server listening on %s", _SOCKET_NAME)
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server:
|
||||
self._server.close()
|
||||
QLocalServer.removeServer(_SOCKET_NAME)
|
||||
self._server = None
|
||||
|
||||
def _on_connection(self) -> None:
|
||||
conn = self._server.nextPendingConnection()
|
||||
if conn:
|
||||
conn.readyRead.connect(lambda: self._read(conn))
|
||||
|
||||
def _read(self, conn: QLocalSocket) -> None:
|
||||
data = bytes(conn.readAll()).decode(errors="replace").strip()
|
||||
conn.disconnectFromServer()
|
||||
if data.startswith("nxm://"):
|
||||
logger.info("NXM IPC received URL: %s", data)
|
||||
self.url_received.emit(data)
|
||||
else:
|
||||
logger.warning("NXM IPC received unexpected data: %r", data[:80])
|
||||
|
||||
|
||||
def send_to_running_instance(url: str) -> bool:
|
||||
"""Send an nxm:// URL to the running Jackify instance.
|
||||
|
||||
Returns True if a running instance was found and the URL was delivered.
|
||||
"""
|
||||
socket = QLocalSocket()
|
||||
socket.connectToServer(_SOCKET_NAME)
|
||||
if not socket.waitForConnected(_CONNECT_TIMEOUT_MS):
|
||||
return False
|
||||
socket.write(url.encode())
|
||||
socket.flush()
|
||||
socket.waitForBytesWritten(500)
|
||||
socket.disconnectFromServer()
|
||||
logger.debug("NXM URL handed off to running instance")
|
||||
return True
|
||||
@@ -0,0 +1,124 @@
|
||||
"""NXM protocol handler registration.
|
||||
|
||||
Updates (or creates) the Jackify .desktop file to include
|
||||
x-scheme-handler/nxm in its MimeType, then registers it with xdg.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DESKTOP_FILE = Path.home() / ".local" / "share" / "applications" / "com.jackify.app.desktop"
|
||||
_NXM_MIME = "x-scheme-handler/nxm"
|
||||
|
||||
|
||||
def ensure_nxm_registered() -> bool:
|
||||
"""Add nxm:// handler to the existing Jackify .desktop file if not present.
|
||||
|
||||
Safe to call on every launch - no-ops when already registered.
|
||||
Returns True on success.
|
||||
"""
|
||||
try:
|
||||
if not _DESKTOP_FILE.exists():
|
||||
if not _create_desktop_file():
|
||||
return False
|
||||
|
||||
content = _DESKTOP_FILE.read_text()
|
||||
if _NXM_MIME in content:
|
||||
logger.debug("nxm:// already registered in desktop file")
|
||||
return True
|
||||
|
||||
# Add nxm to existing MimeType= line
|
||||
updated = False
|
||||
lines = content.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith("MimeType="):
|
||||
if not line.rstrip().endswith(";"):
|
||||
lines[i] = line.rstrip() + ";"
|
||||
lines[i] = lines[i] + f"{_NXM_MIME};"
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
# No MimeType line - append one
|
||||
lines.append(f"MimeType={_NXM_MIME};")
|
||||
|
||||
_DESKTOP_FILE.write_text("\n".join(lines) + "\n")
|
||||
logger.info("Added nxm:// to desktop file MimeType")
|
||||
|
||||
_run_xdg_registration()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to register nxm:// protocol: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _create_desktop_file() -> bool:
|
||||
"""Create a minimal .desktop file with both jackify:// and nxm:// handlers."""
|
||||
try:
|
||||
env = os.environ
|
||||
is_appimage = (
|
||||
"APPIMAGE" in env or "APPDIR" in env or
|
||||
(sys.argv[0] and sys.argv[0].endswith(".AppImage"))
|
||||
)
|
||||
if is_appimage:
|
||||
exec_path = env.get("APPIMAGE") or str(Path(sys.argv[0]).resolve())
|
||||
exec_line = f'Exec="{exec_path}" %u'
|
||||
else:
|
||||
src_dir = Path(__file__).resolve().parent.parent.parent.parent
|
||||
exec_path = f'bash -c \'cd "{src_dir}" && "{sys.executable}" -m jackify.frontends.gui "$@"\' --'
|
||||
exec_line = f"Exec={exec_path} %u"
|
||||
|
||||
_DESKTOP_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_DESKTOP_FILE.write_text(
|
||||
"[Desktop Entry]\n"
|
||||
"Type=Application\n"
|
||||
"Name=Jackify\n"
|
||||
"Comment=Wabbajack modlist manager for Linux\n"
|
||||
f"{exec_line}\n"
|
||||
"Icon=com.jackify.app\n"
|
||||
"Terminal=false\n"
|
||||
"Categories=Game;Utility;\n"
|
||||
f"MimeType=x-scheme-handler/jackify;{_NXM_MIME};\n"
|
||||
)
|
||||
logger.info("Created desktop file at %s", _DESKTOP_FILE)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Failed to create desktop file: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _run_xdg_registration() -> None:
|
||||
apps_dir = _DESKTOP_FILE.parent
|
||||
for cmd in [
|
||||
["update-desktop-database", str(apps_dir)],
|
||||
["xdg-mime", "default", _DESKTOP_FILE.name, _NXM_MIME],
|
||||
["xdg-settings", "set", "default-url-scheme-handler", "nxm", _DESKTOP_FILE.name],
|
||||
]:
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, timeout=10)
|
||||
except Exception as e:
|
||||
logger.debug("xdg command %s failed (non-fatal): %s", cmd[0], e)
|
||||
|
||||
# mimeapps.list fallback for DEs that ignore xdg-settings
|
||||
mimeapps = Path.home() / ".config" / "mimeapps.list"
|
||||
try:
|
||||
content = mimeapps.read_text() if mimeapps.exists() else "[Default Applications]\n"
|
||||
if f"{_NXM_MIME}=" not in content:
|
||||
if "[Default Applications]" not in content:
|
||||
content = "[Default Applications]\n" + content
|
||||
lines = content.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "[Default Applications]":
|
||||
lines.insert(i + 1, f"{_NXM_MIME}={_DESKTOP_FILE.name}")
|
||||
break
|
||||
mimeapps.parent.mkdir(parents=True, exist_ok=True)
|
||||
mimeapps.write_text("\n".join(lines))
|
||||
logger.info("Added nxm handler to mimeapps.list")
|
||||
except Exception as e:
|
||||
logger.debug("mimeapps.list update failed (non-fatal): %s", e)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""NXM session state: remembers which modlist to route downloads to.
|
||||
|
||||
Clears when the process exits. Not persisted to disk.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_remembered_modlist: Optional[str] = None
|
||||
|
||||
|
||||
def get_remembered_modlist() -> Optional[str]:
|
||||
return _remembered_modlist
|
||||
|
||||
|
||||
def set_remembered_modlist(name: str) -> None:
|
||||
global _remembered_modlist
|
||||
_remembered_modlist = name
|
||||
|
||||
|
||||
def clear_remembered_modlist() -> None:
|
||||
global _remembered_modlist
|
||||
_remembered_modlist = None
|
||||
|
||||
|
||||
def detect_active_mo2_modlist(modlists: List[Dict]) -> Optional[Dict]:
|
||||
"""Return the modlist whose MO2 instance is currently running, or None.
|
||||
|
||||
Scans live processes for ModOrganizer.exe and matches the install path
|
||||
against the provided modlist list.
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
logger.debug("psutil not available, skipping MO2 process detection")
|
||||
return None
|
||||
|
||||
mo2_dirs: List[Path] = []
|
||||
try:
|
||||
for proc in psutil.process_iter(["cmdline"]):
|
||||
try:
|
||||
cmdline = proc.info.get("cmdline") or []
|
||||
for arg in cmdline:
|
||||
arg_str = str(arg)
|
||||
if "ModOrganizer.exe" in arg_str:
|
||||
resolved = _resolve_mo2_path(arg_str)
|
||||
if resolved:
|
||||
mo2_dirs.append(resolved)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("MO2 process scan failed: %s", e)
|
||||
return None
|
||||
|
||||
if not mo2_dirs:
|
||||
return None
|
||||
|
||||
matches = []
|
||||
for modlist in modlists:
|
||||
ml_dir = Path(modlist.get("modlist_dir", "")).resolve()
|
||||
for mo2_dir in mo2_dirs:
|
||||
try:
|
||||
if mo2_dir.resolve() == ml_dir:
|
||||
matches.append(modlist)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if len(matches) == 1:
|
||||
logger.debug("Active MO2 instance matched modlist: %s", matches[0].get("name"))
|
||||
return matches[0]
|
||||
|
||||
if len(matches) > 1:
|
||||
logger.debug("Multiple active MO2 instances found, falling back to picker")
|
||||
else:
|
||||
logger.debug("MO2 process found but no modlist match for dirs: %s", mo2_dirs)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_mo2_path(arg: str) -> Optional[Path]:
|
||||
"""Extract and resolve the modlist directory from a ModOrganizer.exe cmdline arg."""
|
||||
# Wine path: Z:\path\to\modlist\ModOrganizer.exe
|
||||
m = re.match(r"(?i)z:([\\/].+?)[\\/]ModOrganizer\.exe", arg)
|
||||
if m:
|
||||
linux_path = m.group(1).replace("\\", "/")
|
||||
return Path(linux_path)
|
||||
|
||||
# Raw Linux path: /path/to/modlist/ModOrganizer.exe
|
||||
m = re.match(r"(/.+?)/ModOrganizer\.exe", arg)
|
||||
if m:
|
||||
return Path(m.group(1))
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,60 @@
|
||||
"""NXM URL parser.
|
||||
|
||||
nxm://{game}/mods/{mod_id}/files/{file_id}?key=KEY&expires=TS&user_id=UID
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
|
||||
@dataclass
|
||||
class NxmUrl:
|
||||
game: str
|
||||
mod_id: int
|
||||
file_id: int
|
||||
key: str
|
||||
expires: str
|
||||
user_id: Optional[str] = None
|
||||
raw: str = ""
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return f"{self.game} / mod {self.mod_id} / file {self.file_id}"
|
||||
|
||||
|
||||
def parse_nxm_url(url: str) -> NxmUrl:
|
||||
"""Parse an nxm:// URL into its components.
|
||||
|
||||
Raises ValueError if the URL is malformed.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() != "nxm":
|
||||
raise ValueError(f"Not an NXM URL: {url}")
|
||||
|
||||
game = parsed.netloc.lower()
|
||||
parts = [p for p in parsed.path.strip("/").split("/") if p]
|
||||
# Expected: ['mods', '{mod_id}', 'files', '{file_id}']
|
||||
if len(parts) < 4 or parts[0] != "mods" or parts[2] != "files":
|
||||
raise ValueError(f"Unexpected NXM URL path: {parsed.path}")
|
||||
|
||||
try:
|
||||
mod_id = int(parts[1])
|
||||
file_id = int(parts[3])
|
||||
except ValueError:
|
||||
raise ValueError(f"Non-integer mod/file ID in NXM URL: {url}")
|
||||
|
||||
params = parse_qs(parsed.query)
|
||||
key = params.get("key", [""])[0]
|
||||
expires = params.get("expires", [""])[0]
|
||||
user_id = params.get("user_id", [None])[0]
|
||||
|
||||
return NxmUrl(
|
||||
game=game,
|
||||
mod_id=mod_id,
|
||||
file_id=file_id,
|
||||
key=key,
|
||||
expires=expires,
|
||||
user_id=user_id,
|
||||
raw=url,
|
||||
)
|
||||
@@ -153,12 +153,13 @@ def detect_game_type_from_modlist(modlist_dir: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_artwork(game_type: str, dest_dir: Path) -> int:
|
||||
def fetch_artwork(game_type: str, dest_dir: Path, skip_existing: bool = False) -> 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.
|
||||
dest_dir must already exist. When skip_existing is True, slots where the
|
||||
file already exists in dest_dir are skipped.
|
||||
"""
|
||||
steam_appid = GAME_STEAM_APP_IDS.get(game_type)
|
||||
if not steam_appid:
|
||||
@@ -168,12 +169,14 @@ def fetch_artwork(game_type: str, dest_dir: Path) -> int:
|
||||
api_key = _get_api_key()
|
||||
downloaded = 0
|
||||
for endpoint, query, filename in _ARTWORK_SLOTS:
|
||||
dest_path = dest_dir / filename
|
||||
if skip_existing and dest_path.exists():
|
||||
continue
|
||||
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
|
||||
|
||||
@@ -8,11 +8,13 @@ standalone operation for existing prefixes.
|
||||
Based on research into NaK's registry configuration (external reference only).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
@@ -91,11 +93,11 @@ def _build_reg_content() -> str:
|
||||
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.
|
||||
# .NET 9 SDK - ZIP distribution, extracted directly to avoid running an EXE under Wine.
|
||||
# Synthesis requires the SDK (not just runtime) 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"
|
||||
_DOTNET9_SDK_URL = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.310/dotnet-sdk-9.0.310-win-x64.zip"
|
||||
_DOTNET9_SDK_FILENAME = "dotnet-sdk-9.0.310-win-x64.zip"
|
||||
|
||||
# .NET Desktop Runtime 10 - provides NETCore.App + WindowsDesktop.App 10.0.2.
|
||||
# Covers Synthesis patchers targeting .NET 10 runtime.
|
||||
@@ -122,42 +124,29 @@ def _install_dotnet9_sdk(
|
||||
log: Callable[[str], None],
|
||||
) -> bool:
|
||||
"""
|
||||
Download and install the .NET 9 SDK into the Wine prefix.
|
||||
Cached to avoid re-downloading on subsequent runs.
|
||||
Download and extract the .NET 9 SDK ZIP into the Wine prefix.
|
||||
Uses the standalone ZIP distribution to avoid running an EXE under Wine.
|
||||
Synthesis requires the full SDK (Roslyn compiler) for patcher compilation.
|
||||
"""
|
||||
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
|
||||
sdk_zip = cache_dir / _DOTNET9_SDK_FILENAME
|
||||
|
||||
if not installer.exists():
|
||||
if not sdk_zip.exists():
|
||||
log(f"Downloading .NET 9 SDK ({_DOTNET9_SDK_FILENAME})...")
|
||||
urllib.request.urlretrieve(_DOTNET9_SDK_URL, installer)
|
||||
urllib.request.urlretrieve(_DOTNET9_SDK_URL, sdk_zip)
|
||||
log(".NET 9 SDK downloaded")
|
||||
else:
|
||||
log(".NET 9 SDK installer already cached, skipping download")
|
||||
log(".NET 9 SDK 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")
|
||||
dest = prefix_path / "drive_c" / "Program Files" / "dotnet"
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
log("Extracting .NET 9 SDK...")
|
||||
with zipfile.ZipFile(sdk_zip) as zf:
|
||||
zf.extractall(dest)
|
||||
log(".NET 9 SDK extracted successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -362,6 +351,7 @@ def apply_tool_config(
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
install_dotnet9_sdk: bool = False,
|
||||
install_fxc2_d3dcompiler: bool = False,
|
||||
preserve_global_mscoree: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Apply tool compatibility settings to the Wine prefix.
|
||||
@@ -399,20 +389,25 @@ def apply_tool_config(
|
||||
# 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}")
|
||||
# NSF/CSF modlists are the exception: NetScriptFramework's mixed-mode runtime needs the
|
||||
# global override to host the CLR (the per-exe entry alone is insufficient), so it is kept.
|
||||
if preserve_global_mscoree:
|
||||
_log("Preserving global *mscoree=native (NSF/CSF modlist)")
|
||||
else:
|
||||
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()
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
"""
|
||||
Third-party tool registry.
|
||||
Third-party tool registry: install, update, downgrade, and uninstall.
|
||||
|
||||
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.
|
||||
Tool state is stored at $jackify_data_dir/tools/<tool_id>/manifest.json.
|
||||
TTW_Linux_Installer installs into $jackify_data_dir/tools/ttw_installer/ and
|
||||
delegates the download/extract to TTWInstallerHandler.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -32,7 +25,6 @@ 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
|
||||
@@ -44,6 +36,14 @@ class ToolDefinition:
|
||||
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
|
||||
can_downgrade: bool = True # False for pinned tools where version must not change
|
||||
is_engine: bool = False # Engine cards show Set Active instead of Launch
|
||||
can_launch: bool = False # Tool has a launchable binary the user runs directly
|
||||
nexus_mod_id: Optional[int] = None # Nexus mod ID; premium users download from Nexus first
|
||||
nexus_game_domain: str = "site" # Nexus game domain for site-wide tools
|
||||
nexus_file_filter: Optional[str] = None # Substring filter to pick the right Nexus file
|
||||
hidden: bool = False # Set true in manifest to suppress display and installs
|
||||
include_prereleases: bool = False # If True, newest release by date (inc. pre-releases) is used
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -58,15 +58,37 @@ class ToolStatus:
|
||||
|
||||
@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()
|
||||
return (
|
||||
self.installed
|
||||
and self.definition.can_downgrade
|
||||
and self.definition.pinned_version is None
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool catalogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOL_DEFINITIONS: List[ToolDefinition] = [
|
||||
ToolDefinition(
|
||||
tool_id="jackify-engine",
|
||||
display_name="jackify-engine",
|
||||
description="Native Wabbajack-matched file handler. The proven, stable engine for modlist installs.",
|
||||
github_repo="Omni-guides/dev-jackify-engine",
|
||||
asset_patterns=[r"jackify-engine.*linux.*x64.*\.tar\.gz", r"jackify-engine.*\.tar\.gz", r"jackify-engine.*\.zip"],
|
||||
executable_names=["jackify-engine"],
|
||||
tier=1,
|
||||
can_uninstall=False,
|
||||
is_engine=True,
|
||||
),
|
||||
ToolDefinition(
|
||||
tool_id="clf3",
|
||||
display_name="CLF3",
|
||||
description="Rust-based Wabbajack file handler. Faster installs, slightly slower modlist updates than jackify-engine.",
|
||||
github_repo="SulfurNitride/CLF3",
|
||||
asset_patterns=[r"clf3.*linux.*x86_64.*\.tar\.gz", r"clf3.*\.tar\.gz", r"clf3.*\.zip"],
|
||||
executable_names=["clf3"],
|
||||
tier=1,
|
||||
can_uninstall=True,
|
||||
is_engine=True,
|
||||
include_prereleases=True,
|
||||
),
|
||||
ToolDefinition(
|
||||
tool_id="ttw_installer",
|
||||
display_name="TTW Linux Installer",
|
||||
@@ -75,58 +97,130 @@ TOOL_DEFINITIONS: List[ToolDefinition] = [
|
||||
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,
|
||||
can_launch=True,
|
||||
pinned_version="0.0.7", # must match TTW_INSTALLER_PINNED_VERSION in ttw_installer_handler.py
|
||||
nexus_mod_id=1657,
|
||||
nexus_file_filter="mpi",
|
||||
),
|
||||
ToolDefinition(
|
||||
tool_id="radium",
|
||||
display_name="Radium Textures",
|
||||
description="Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation.",
|
||||
description="Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation. Run directly against mod files.",
|
||||
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,
|
||||
can_launch=True,
|
||||
nexus_mod_id=1660,
|
||||
nexus_file_filter="linux",
|
||||
),
|
||||
]
|
||||
|
||||
_TOOL_MAP: Dict[str, ToolDefinition] = {t.tool_id: t for t in TOOL_DEFINITIONS}
|
||||
|
||||
ENGINE_TOOL_IDS: List[str] = [t.tool_id for t in TOOL_DEFINITIONS if t.is_engine]
|
||||
_DEFAULT_ENGINE = "jackify-engine"
|
||||
_ACTIVE_ENGINE_CONFIG_KEY = "active_engine"
|
||||
|
||||
|
||||
def get_active_engine_id() -> str:
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
val = ConfigHandler().get(_ACTIVE_ENGINE_CONFIG_KEY, _DEFAULT_ENGINE)
|
||||
return val if val in ENGINE_TOOL_IDS else _DEFAULT_ENGINE
|
||||
except Exception:
|
||||
return _DEFAULT_ENGINE
|
||||
|
||||
def set_active_engine_id(tool_id: str) -> None:
|
||||
if tool_id not in ENGINE_TOOL_IDS:
|
||||
raise ValueError(f"Not an engine: {tool_id}")
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
cfg = ConfigHandler()
|
||||
cfg.set(_ACTIVE_ENGINE_CONFIG_KEY, tool_id)
|
||||
cfg.save_config()
|
||||
except Exception as e:
|
||||
logger.warning("Could not persist active engine selection: %s", e)
|
||||
|
||||
|
||||
# -- remote manifest ---------------------------------------------------------
|
||||
TOOL_MANIFEST_URL = "https://raw.githubusercontent.com/Omni-guides/Jackify/main/tools_manifest.json"
|
||||
_BUNDLED_MANIFEST_PATH = Path(__file__).parent / "tools_manifest.json"
|
||||
|
||||
|
||||
def _parse_manifest_entries(entries: list) -> Optional[List[ToolDefinition]]:
|
||||
definitions = []
|
||||
for entry in entries:
|
||||
try:
|
||||
definitions.append(ToolDefinition(
|
||||
tool_id=entry["tool_id"],
|
||||
display_name=entry["display_name"],
|
||||
description=entry["description"],
|
||||
github_repo=entry["github_repo"],
|
||||
asset_patterns=entry["asset_patterns"],
|
||||
tier=entry.get("tier", 2),
|
||||
executable_names=entry.get("executable_names", []),
|
||||
pinned_version=entry.get("pinned_version"),
|
||||
can_uninstall=entry.get("can_uninstall", True),
|
||||
can_downgrade=entry.get("can_downgrade", True),
|
||||
is_engine=entry.get("is_engine", False),
|
||||
can_launch=entry.get("can_launch", False),
|
||||
nexus_mod_id=entry.get("nexus_mod_id"),
|
||||
nexus_game_domain=entry.get("nexus_game_domain", "site"),
|
||||
nexus_file_filter=entry.get("nexus_file_filter"),
|
||||
hidden=entry.get("hidden", False),
|
||||
))
|
||||
except (KeyError, TypeError) as e:
|
||||
logger.warning("Skipping malformed manifest entry: %s", e)
|
||||
return definitions if definitions else None
|
||||
|
||||
|
||||
def _load_bundled_manifest() -> Optional[List[ToolDefinition]]:
|
||||
try:
|
||||
with open(_BUNDLED_MANIFEST_PATH, "r", encoding="utf-8") as fh:
|
||||
entries = json.load(fh)
|
||||
if not isinstance(entries, list):
|
||||
return None
|
||||
return _parse_manifest_entries(entries)
|
||||
except Exception as e:
|
||||
logger.debug("Bundled manifest load failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
_manifest_cache: Optional[List[ToolDefinition]] = _load_bundled_manifest()
|
||||
|
||||
|
||||
def fetch_remote_manifest() -> Optional[List[ToolDefinition]]:
|
||||
"""Fetch the remote tool manifest. Returns parsed definitions or None on failure."""
|
||||
try:
|
||||
resp = requests.get(TOOL_MANIFEST_URL, timeout=8, verify=True)
|
||||
resp.raise_for_status()
|
||||
entries = resp.json()
|
||||
if not isinstance(entries, list):
|
||||
return None
|
||||
return _parse_manifest_entries(entries)
|
||||
except Exception as e:
|
||||
logger.debug("Tool manifest fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
def get_effective_definitions() -> List[ToolDefinition]:
|
||||
"""Remote manifest definitions if fetched this session, else baked-in TOOL_DEFINITIONS."""
|
||||
source = _manifest_cache if _manifest_cache is not None else TOOL_DEFINITIONS
|
||||
return [d for d in source if not d.hidden]
|
||||
|
||||
def apply_remote_manifest(definitions: List[ToolDefinition]) -> None:
|
||||
"""Store fetched manifest as session cache and rebuild the tool map."""
|
||||
global _manifest_cache, _TOOL_MAP
|
||||
_manifest_cache = definitions
|
||||
_TOOL_MAP = {t.tool_id: t for t in 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():
|
||||
@@ -143,34 +237,25 @@ def _write_manifest(tool_id: str, data: dict) -> None:
|
||||
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
|
||||
search_dirs = [
|
||||
TOOLS_BASE_DIR / "ttw_installer",
|
||||
get_jackify_data_dir() / "TTW_Linux_Installer", # legacy location
|
||||
]
|
||||
for tool_dir in search_dirs:
|
||||
for exe_name in ["ttw_linux_gui", "mpi_installer"]:
|
||||
exe = tool_dir / exe_name
|
||||
if exe.is_file():
|
||||
manifest = _read_manifest("ttw_installer")
|
||||
version = manifest.get("installed_version")
|
||||
return True, version, exe
|
||||
return False, None, None
|
||||
except Exception as e:
|
||||
logger.debug("TTW config read failed: %s", e)
|
||||
logger.debug("TTW status check 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:
|
||||
@@ -188,12 +273,27 @@ def fetch_latest_release_info(github_repo: str, pinned_version: Optional[str] =
|
||||
try:
|
||||
resp = requests.get(url, timeout=10, verify=True)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
data = resp.json()
|
||||
tag = data.get("tag_name") or data.get("name", "unknown")
|
||||
logger.info("Latest release for %s: %s", github_repo, tag)
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.debug("GitHub fetch error for %s: %s", github_repo, e)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_release_list(github_repo: str, max_count: int = 10) -> List[dict]:
|
||||
"""Return a list of release dicts (tag_name, name, published_at) from GitHub, newest first."""
|
||||
url = f"https://api.github.com/repos/{github_repo}/releases?per_page={max_count}"
|
||||
try:
|
||||
resp = requests.get(url, timeout=10, verify=True)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.debug("Release list fetch failed for %s: %s", github_repo, e)
|
||||
return []
|
||||
|
||||
|
||||
def _find_asset(release_data: dict, asset_patterns: List[str]) -> Optional[dict]:
|
||||
assets = release_data.get("assets", [])
|
||||
for pattern in asset_patterns:
|
||||
@@ -203,48 +303,149 @@ def _find_asset(release_data: dict, asset_patterns: List[str]) -> Optional[dict]
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core install logic (shared across all non-TTW tools)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _find_sums_asset(release_data: dict, asset_name: str) -> Optional[dict]:
|
||||
"""Find a .SHA256SUMS release asset that covers the given filename."""
|
||||
assets = release_data.get("assets", [])
|
||||
stem = asset_name.rsplit(".", 2)[0] if asset_name.endswith(".tar.gz") else Path(asset_name).stem
|
||||
for asset in assets:
|
||||
name = asset.get("name", "")
|
||||
if name.endswith(".SHA256SUMS") and stem in name:
|
||||
return asset
|
||||
for asset in assets:
|
||||
if asset.get("name", "").endswith(".SHA256SUMS"):
|
||||
return asset
|
||||
return None
|
||||
|
||||
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."""
|
||||
|
||||
def _verify_sha256_sums(sums_path: Path, target_path: Path) -> Tuple[bool, str]:
|
||||
"""Parse a SHA256SUMS file and verify target_path. Format: 'hash filename'."""
|
||||
import hashlib
|
||||
try:
|
||||
expected_hash = None
|
||||
for line in sums_path.read_text().strip().splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1] == target_path.name:
|
||||
expected_hash = parts[0].lower()
|
||||
break
|
||||
if not expected_hash:
|
||||
return False, f"No entry for {target_path.name} in SHA256SUMS file"
|
||||
sha256 = hashlib.sha256()
|
||||
with open(target_path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""):
|
||||
sha256.update(chunk)
|
||||
actual = sha256.hexdigest().lower()
|
||||
if actual != expected_hash:
|
||||
return False, f"SHA256 mismatch for {target_path.name}"
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
return False, f"SHA256 verification error: {e}"
|
||||
|
||||
|
||||
def _extract_archive(file_path: Path, target_dir: Path) -> Tuple[bool, str]:
|
||||
"""Extract an archive or chmod an AppImage in place. Removes the archive on success."""
|
||||
name_lower = file_path.name.lower()
|
||||
is_archive = False
|
||||
try:
|
||||
if name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
|
||||
is_archive = True
|
||||
with tarfile.open(file_path, "r:gz") as tf:
|
||||
tf.extractall(path=target_dir)
|
||||
elif name_lower.endswith(".zip"):
|
||||
is_archive = True
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
zf.extractall(path=target_dir)
|
||||
elif name_lower.endswith(".appimage"):
|
||||
file_path.chmod(0o755)
|
||||
else:
|
||||
return False, f"Unsupported format: {file_path.name}"
|
||||
finally:
|
||||
if is_archive:
|
||||
try:
|
||||
file_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if is_archive:
|
||||
_chmod_elf_binaries(target_dir)
|
||||
return True, ""
|
||||
|
||||
|
||||
def _chmod_elf_binaries(directory: Path) -> None:
|
||||
"""Set executable bit on any ELF binaries found directly in directory."""
|
||||
ELF_MAGIC = b'\x7fELF'
|
||||
for f in directory.iterdir():
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
with open(f, 'rb') as fh:
|
||||
magic = fh.read(4)
|
||||
if magic == ELF_MAGIC:
|
||||
f.chmod(f.stat().st_mode | 0o111)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _download_and_extract(
|
||||
tool_id: str,
|
||||
asset: dict,
|
||||
target_dir: Path,
|
||||
sums_asset: Optional[dict] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Download a GitHub release asset, optionally verify SHA256, then extract."""
|
||||
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:
|
||||
if sums_asset:
|
||||
sums_url = sums_asset.get("browser_download_url", "")
|
||||
sums_path = target_dir / sums_asset.get("name", "SHA256SUMS")
|
||||
if sums_url and fs.download_file(sums_url, sums_path, overwrite=True, quiet=True):
|
||||
ok, err = _verify_sha256_sums(sums_path, temp_path)
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
sums_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if not ok:
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return False, err
|
||||
logger.info("SHA256 verified for %s", asset_name)
|
||||
else:
|
||||
logger.warning("SHA256SUMS download failed for %s, skipping verification", asset_name)
|
||||
return _extract_archive(temp_path, target_dir)
|
||||
|
||||
return True, ""
|
||||
|
||||
def _try_nexus_download(defn: ToolDefinition, target_dir: Path) -> Tuple[bool, Optional[Path], str]:
|
||||
"""Attempt Nexus CDN download for premium users. Returns (success, file_path, message)."""
|
||||
if not defn.nexus_mod_id:
|
||||
return False, None, "No Nexus mod configured"
|
||||
try:
|
||||
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
||||
from jackify.backend.services.nexus_premium_service import NexusPremiumService
|
||||
from jackify.backend.services.nexus_download_service import NexusDownloadService
|
||||
auth = NexusAuthService()
|
||||
token = auth.get_auth_token()
|
||||
if not token:
|
||||
return False, None, "No Nexus auth token"
|
||||
is_oauth = auth.get_auth_method() == "oauth"
|
||||
is_premium, _ = NexusPremiumService().check_premium_status(token, is_oauth=is_oauth)
|
||||
if not is_premium:
|
||||
return False, None, "Not Nexus Premium"
|
||||
ok, path, msg = NexusDownloadService(token).download_latest_file(
|
||||
defn.nexus_game_domain, defn.nexus_mod_id, target_dir,
|
||||
file_name_filter=defn.nexus_file_filter,
|
||||
)
|
||||
return ok, path, msg
|
||||
except Exception as e:
|
||||
logger.debug("Nexus download attempt failed for %s: %s", defn.tool_id, e)
|
||||
return False, None, str(e)
|
||||
|
||||
|
||||
def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Path]:
|
||||
@@ -255,17 +456,12 @@ def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Pat
|
||||
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."""
|
||||
|
||||
@@ -276,40 +472,58 @@ class ToolRegistry:
|
||||
return self._build_status(defn)
|
||||
|
||||
def get_all_statuses(self) -> List[ToolStatus]:
|
||||
return [self._build_status(d) for d in TOOL_DEFINITIONS]
|
||||
return [self._build_status(d) for d in get_effective_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 defn.pinned_version:
|
||||
return defn.pinned_version
|
||||
if defn.include_prereleases:
|
||||
releases = fetch_release_list(defn.github_repo, max_count=5)
|
||||
if releases:
|
||||
data = releases[0]
|
||||
return data.get("tag_name") or data.get("name")
|
||||
return None
|
||||
data = fetch_latest_release_info(defn.github_repo)
|
||||
if data:
|
||||
return data.get("tag_name") or data.get("name")
|
||||
return None
|
||||
|
||||
def install(self, tool_id: str) -> Tuple[bool, str]:
|
||||
def install(self, tool_id: str, version: Optional[str] = None) -> Tuple[bool, str]:
|
||||
defn = _TOOL_MAP.get(tool_id)
|
||||
if defn is None:
|
||||
return False, f"Unknown tool: {tool_id}"
|
||||
|
||||
if defn.hidden:
|
||||
return False, f"{defn.display_name} is not available for install"
|
||||
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}"
|
||||
pin = version or defn.pinned_version
|
||||
nexus_ok, nexus_path, _ = _try_nexus_download(defn, install_dir) if not version else (False, None, "")
|
||||
if nexus_ok and nexus_path:
|
||||
ok, err = _extract_archive(nexus_path, install_dir)
|
||||
tag = pin or "nexus"
|
||||
else:
|
||||
if defn.include_prereleases and not pin:
|
||||
releases = fetch_release_list(defn.github_repo, max_count=5)
|
||||
data = releases[0] if releases else None
|
||||
else:
|
||||
data = fetch_latest_release_info(defn.github_repo, pin)
|
||||
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")
|
||||
sums_asset = _find_sums_asset(data, asset.get("name", ""))
|
||||
ok, err = _download_and_extract(tool_id, asset, install_dir, sums_asset=sums_asset)
|
||||
|
||||
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
|
||||
|
||||
@@ -332,7 +546,6 @@ class ToolRegistry:
|
||||
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}"
|
||||
@@ -447,7 +660,6 @@ class ToolRegistry:
|
||||
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
|
||||
@@ -478,6 +690,21 @@ class ToolRegistry:
|
||||
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())
|
||||
|
||||
if not installed and defn.tool_id == "jackify-engine":
|
||||
try:
|
||||
from jackify.backend.core.modlist_operations import get_jackify_engine_path
|
||||
bundled = Path(get_jackify_engine_path())
|
||||
if bundled.is_file():
|
||||
installed = True
|
||||
binary_path = bundled
|
||||
if not installed_version:
|
||||
version_file = bundled.parent / "version.txt"
|
||||
if version_file.is_file():
|
||||
installed_version = version_file.read_text().strip() or None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ToolStatus(
|
||||
definition=defn,
|
||||
installed=installed,
|
||||
@@ -487,7 +714,7 @@ class ToolRegistry:
|
||||
)
|
||||
|
||||
def _install_ttw(self) -> Tuple[bool, str]:
|
||||
"""Delegate TTW install to the existing handler."""
|
||||
"""Delegate TTW install to the existing handler, installing into the tools directory."""
|
||||
try:
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
@@ -498,6 +725,16 @@ class ToolRegistry:
|
||||
steamdeck=False, verbose=False,
|
||||
filesystem_handler=fs, config_handler=cfg,
|
||||
)
|
||||
return handler.install_ttw_installer()
|
||||
install_dir = TOOLS_BASE_DIR / "ttw_installer"
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
ok, msg = handler.install_ttw_installer(install_dir=install_dir)
|
||||
if ok:
|
||||
version = cfg.get("ttw_installer_version") or "unknown"
|
||||
exe_path = _find_executable(_TOOL_MAP["ttw_installer"], install_dir)
|
||||
_write_manifest("ttw_installer", {
|
||||
"installed_version": version,
|
||||
"binary_path": str(exe_path) if exe_path else None,
|
||||
})
|
||||
return ok, msg
|
||||
except Exception as e:
|
||||
return False, f"TTW install failed: {e}"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
[
|
||||
{
|
||||
"tool_id": "jackify-engine",
|
||||
"display_name": "jackify-engine",
|
||||
"description": "Native Wabbajack-matched file handler. The proven, stable engine for modlist installs.",
|
||||
"github_repo": "Omni-guides/jackify-engine",
|
||||
"asset_patterns": ["jackify-engine.*linux.*x64.*\\.tar\\.gz", "jackify-engine.*\\.tar\\.gz", "jackify-engine.*\\.zip"],
|
||||
"executable_names": ["jackify-engine"],
|
||||
"tier": 1,
|
||||
"can_uninstall": false,
|
||||
"is_engine": true,
|
||||
"can_launch": false
|
||||
},
|
||||
{
|
||||
"tool_id": "clf3",
|
||||
"display_name": "CLF3",
|
||||
"description": "Rust-based Wabbajack file handler. Faster installs, slightly slower modlist updates than jackify-engine.",
|
||||
"github_repo": "SulfurNitride/CLF3",
|
||||
"asset_patterns": ["clf3.*linux.*x86_64", "clf3.*\\.tar\\.gz", "clf3.*\\.zip"],
|
||||
"executable_names": ["clf3"],
|
||||
"tier": 1,
|
||||
"can_uninstall": true,
|
||||
"is_engine": true,
|
||||
"can_launch": false
|
||||
},
|
||||
{
|
||||
"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": ["universal-mpi-installer.*\\.(zip|tar\\.gz)"],
|
||||
"executable_names": ["mpi_installer", "ttw_linux_gui"],
|
||||
"tier": 1,
|
||||
"can_uninstall": true,
|
||||
"can_launch": true,
|
||||
"pinned_version": "0.0.7",
|
||||
"nexus_mod_id": 1657,
|
||||
"nexus_game_domain": "site",
|
||||
"nexus_file_filter": "mpi"
|
||||
},
|
||||
{
|
||||
"tool_id": "radium",
|
||||
"display_name": "Radium Textures",
|
||||
"description": "Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation. Run directly against mod files.",
|
||||
"github_repo": "SulfurNitride/Radium-Textures",
|
||||
"asset_patterns": ["radium.*linux.*x86_64", "radium.*\\.tar\\.gz", "radium.*\\.zip"],
|
||||
"executable_names": ["radium", "radium-textures"],
|
||||
"tier": 2,
|
||||
"can_uninstall": true,
|
||||
"can_launch": true,
|
||||
"nexus_mod_id": 1660,
|
||||
"nexus_game_domain": "site",
|
||||
"nexus_file_filter": "linux"
|
||||
}
|
||||
]
|
||||
@@ -24,11 +24,8 @@ def _build_handler() -> TTWInstallerHandler:
|
||||
|
||||
def get_ttw_installer_path() -> Optional[Path]:
|
||||
"""Return the resolved TTW_Linux_Installer executable path, if available."""
|
||||
handler = _build_handler()
|
||||
path = handler.ttw_installer_executable_path
|
||||
if path and path.exists():
|
||||
return path
|
||||
return None
|
||||
from jackify.backend.services.tool_registry import ToolRegistry
|
||||
return ToolRegistry().get_binary_path("ttw_installer")
|
||||
|
||||
|
||||
def ensure_ttw_installer_available(
|
||||
@@ -47,13 +44,17 @@ def ensure_ttw_installer_available(
|
||||
if progress_callback:
|
||||
progress_callback("TTW_Linux_Installer not found, installing...")
|
||||
|
||||
from jackify.backend.services.tool_registry import TOOLS_BASE_DIR
|
||||
install_dir = TOOLS_BASE_DIR / "ttw_installer"
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler = _build_handler()
|
||||
success, message = handler.install_ttw_installer()
|
||||
success, message = handler.install_ttw_installer(install_dir=install_dir)
|
||||
if not success:
|
||||
logger.error("Failed to install TTW_Linux_Installer: %s", message)
|
||||
return None, message
|
||||
|
||||
path = handler.ttw_installer_executable_path
|
||||
path = get_ttw_installer_path()
|
||||
if path and path.exists():
|
||||
if progress_callback:
|
||||
progress_callback("TTW_Linux_Installer installed successfully")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Post-install fixups applied after a CLF3 install completes.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SDCARD_RE = re.compile(r'^/run/media/')
|
||||
|
||||
|
||||
def _linux_to_wine_path(path_str: str) -> str:
|
||||
"""Convert an absolute Linux path to a Wine-format path (Z: or D: for SD card)."""
|
||||
if _SDCARD_RE.match(path_str):
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
stripped = WineUtils._strip_sdcard_path(path_str).lstrip('/')
|
||||
return "D:\\" + stripped.replace('/', '\\')
|
||||
return "Z:\\" + path_str.lstrip('/').replace('/', '\\')
|
||||
|
||||
|
||||
def inject_mo2_download_dir(install_dir: str, downloads_dir: str) -> None:
|
||||
"""Ensure ModOrganizer.ini contains download_directory pointing at downloads_dir.
|
||||
|
||||
Some modlists ship a bundled ModOrganizer.ini that omits download_directory.
|
||||
MO2 then defaults to its own internal path, causing it to report all downloads
|
||||
as missing on first launch. This injects the correct path if absent.
|
||||
"""
|
||||
ini_path = Path(install_dir) / "ModOrganizer.ini"
|
||||
if not ini_path.exists():
|
||||
return
|
||||
|
||||
content = ini_path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if "download_directory=" in content.lower():
|
||||
return
|
||||
|
||||
wine_path = _linux_to_wine_path(str(downloads_dir))
|
||||
entry = f"download_directory={wine_path}"
|
||||
|
||||
lines = content.splitlines(keepends=True)
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().lower() == "[general]":
|
||||
lines.insert(i + 1, entry + "\n")
|
||||
ini_path.write_text("".join(lines), encoding="utf-8")
|
||||
logger.info(f"Injected download_directory into {ini_path}: {wine_path}")
|
||||
return
|
||||
|
||||
logger.debug(f"ModOrganizer.ini has no [General] section, skipping injection: {ini_path}")
|
||||
@@ -7,6 +7,42 @@ from jackify.shared.errors import (
|
||||
game_not_found_for_modlist,
|
||||
)
|
||||
|
||||
_HTTP_TIMEOUT_RE = re.compile(
|
||||
r"httpclient\.timeout|request was canceled.*timeout|timed?\s*out.*elaps",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_NEXUS_DOWNLOAD_RE = re.compile(
|
||||
r"Game:\s*([A-Za-z0-9]+),\s*ModID:\s*(\d+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_NEXUS_GAME_SLUGS = {
|
||||
"skyrimspecialedition": "skyrimspecialedition",
|
||||
"skyrim": "skyrim",
|
||||
"skyrimvr": "skyrimvr",
|
||||
"fallout4": "fallout4",
|
||||
"fallout4vr": "fallout4vr",
|
||||
"falloutneeds": "newvegas",
|
||||
"falloutnewvegas": "newvegas",
|
||||
"fallout3": "fallout3",
|
||||
"oblivion": "oblivion",
|
||||
"oblivionremastered": "oblivionremastered",
|
||||
"starfield": "starfield",
|
||||
"baldursgate3": "baldursgate3",
|
||||
}
|
||||
|
||||
|
||||
def nexus_url_from_error_line(line: str) -> Optional[str]:
|
||||
"""Extract a Nexus mod page URL from an engine download-failure line, or return None."""
|
||||
m = _NEXUS_DOWNLOAD_RE.search(line)
|
||||
if not m:
|
||||
return None
|
||||
game_raw = m.group(1).lower()
|
||||
mod_id = m.group(2)
|
||||
slug = _NEXUS_GAME_SLUGS.get(game_raw, game_raw)
|
||||
return f"https://www.nexusmods.com/{slug}/mods/{mod_id}"
|
||||
|
||||
|
||||
def _ctx_detail(ctx: dict) -> Optional[str]:
|
||||
if not ctx:
|
||||
@@ -14,9 +50,28 @@ def _ctx_detail(ctx: dict) -> Optional[str]:
|
||||
return format_technical_context(context=ctx)
|
||||
|
||||
|
||||
def _download_timeout_error(detail: str, ctx: dict) -> InstallError:
|
||||
return InstallError(
|
||||
"Download Timed Out",
|
||||
"A download request timed out before completing.",
|
||||
suggestion="Retry the install - Wabbajack will resume from where it stopped.",
|
||||
solutions=[
|
||||
"Re-run the install - Wabbajack resumes and will reattempt timed-out downloads.",
|
||||
"Check your internet connection is stable.",
|
||||
"Disable VPN or proxy if active.",
|
||||
"Check if Nexus Mods is reachable at nexusmods.com.",
|
||||
"If timeouts persist, try installing during off-peak hours or from a faster connection.",
|
||||
],
|
||||
technical=_ctx_detail(ctx) or detail,
|
||||
)
|
||||
|
||||
|
||||
def _engine_error(msg: str, ctx: dict) -> InstallError:
|
||||
"""Map generic engine_error payloads to user-visible, actionable InstallError variants."""
|
||||
text = (msg or "").strip()
|
||||
if _HTTP_TIMEOUT_RE.search(text):
|
||||
return _download_timeout_error(text, ctx)
|
||||
|
||||
match = re.search(r"can't find game\s+([A-Za-z0-9_:-]+)", text, flags=re.IGNORECASE)
|
||||
if match:
|
||||
game_name = match.group(1)
|
||||
@@ -126,12 +181,19 @@ _TYPE_MAP = {
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _exit_code_6_error(detail: str, context: dict) -> JackifyError:
|
||||
if detail and _HTTP_TIMEOUT_RE.search(detail):
|
||||
return _download_timeout_error(detail, context)
|
||||
return wabbajack_install_failed(format_technical_context(detail=detail, context=context) or detail)
|
||||
|
||||
|
||||
_EXIT_CODE_MAP = {
|
||||
2: lambda d, c: _TYPE_MAP["auth_failed"](d, c or {}),
|
||||
3: lambda d, c: _TYPE_MAP["network_error"](d, c or {}),
|
||||
4: lambda d, c: _TYPE_MAP["disk_full"](d, c or {}),
|
||||
5: lambda d, c: _TYPE_MAP["validation_failed"](d, c or {}),
|
||||
6: lambda d, c: wabbajack_install_failed(format_technical_context(detail=d, context=c) or d),
|
||||
6: _exit_code_6_error,
|
||||
}
|
||||
|
||||
|
||||
|
||||
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