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