Release v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support

This commit is contained in:
Omni
2026-06-21 21:47:48 +01:00
parent 33b3fbaed2
commit 7fff107389
483 changed files with 11150 additions and 6050 deletions
+3 -4
View File
@@ -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
View File
@@ -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.
+7 -7
View File
@@ -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
View File
@@ -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
+77
View File
@@ -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}"]
}
}
+13 -2
View File
@@ -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
+1 -25
View File
@@ -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?")
+38 -29
View File
@@ -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
-2
View File
@@ -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',
]
+21 -21
View File
@@ -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:
+34 -11
View File
@@ -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.
+47 -12
View File
@@ -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
+43 -31
View File
@@ -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()
+188
View File
@@ -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",
+17 -18
View File
@@ -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:
+118
View File
@@ -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
+73
View File
@@ -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
+124
View File
@@ -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)
+97
View File
@@ -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
+60
View File
@@ -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
+39 -44
View File
@@ -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()
+360 -123
View File
@@ -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")
+49
View File
@@ -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}")
+63 -1
View File
@@ -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.

Some files were not shown because too many files have changed in this diff Show More