mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 03:47:44 +02:00
Sync from development - prepare for v0.5.0
This commit is contained in:
@@ -17,6 +17,10 @@ from typing import Optional
|
||||
from .config_handler_encryption import ConfigEncryptionMixin
|
||||
from .config_handler_directories import ConfigDirectoriesMixin
|
||||
from .config_handler_proton import ConfigProtonMixin
|
||||
from jackify.shared.steam_utils import (
|
||||
STEAM_PREFERENCE_AUTO,
|
||||
resolve_preferred_steam_installation,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,6 +54,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
"resolution": None,
|
||||
"protontricks_path": None,
|
||||
"steam_path": None,
|
||||
"steam_install_preference": STEAM_PREFERENCE_AUTO, # auto|flatpak|native
|
||||
"nexus_api_key": None, # Base64 encoded API key
|
||||
"default_install_parent_dir": None, # Parent directory for modlist installations
|
||||
"default_download_parent_dir": None, # Parent directory for downloads
|
||||
@@ -62,6 +67,8 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
"proton_path": None, # Install Proton path (for jackify-engine) - None means auto-detect
|
||||
"proton_version": None, # Install Proton version name - None means auto-detect
|
||||
"steam_restart_strategy": "jackify", # "jackify" (default) or "simple"
|
||||
"manual_download_concurrent_limit": 2, # Shared GUI/CLI default for manual download browser tabs
|
||||
"manual_download_watch_directory": None, # Optional override for manual-download watcher folder
|
||||
"window_width": None, # Saved window width (None = use dynamic sizing)
|
||||
"window_height": None # Saved window height (None = use dynamic sizing)
|
||||
}
|
||||
@@ -72,14 +79,13 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
# Perform version migrations
|
||||
self._migrate_config()
|
||||
|
||||
# Normalize/repair Proton selections on every startup so stale deleted versions
|
||||
# cannot break workflows.
|
||||
self.normalize_proton_paths_on_boot()
|
||||
|
||||
# If steam_path is not set, detect it
|
||||
if not self.settings["steam_path"]:
|
||||
self.settings["steam_path"] = self._detect_steam_path()
|
||||
|
||||
# Auto-detect and set Proton version ONLY on first run (config file doesn't exist)
|
||||
# Do NOT overwrite user's saved settings!
|
||||
if not os.path.exists(self.config_file) and not self.settings.get("proton_path"):
|
||||
self._auto_detect_proton()
|
||||
|
||||
# If jackify_data_dir is not set, initialize it to default
|
||||
if not self.settings.get("jackify_data_dir"):
|
||||
@@ -95,35 +101,16 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
str: Path to the Steam installation or None if not found
|
||||
"""
|
||||
logger.info("Detecting Steam installation path...")
|
||||
|
||||
# Common Steam installation paths
|
||||
steam_paths = [
|
||||
os.path.expanduser("~/.steam/steam"),
|
||||
os.path.expanduser("~/.local/share/Steam"),
|
||||
os.path.expanduser("~/.steam/root")
|
||||
]
|
||||
|
||||
# Check each path
|
||||
for path in steam_paths:
|
||||
if os.path.exists(path):
|
||||
logger.info(f"Found Steam installation at: {path}")
|
||||
return path
|
||||
|
||||
# If not found in common locations, try to find using libraryfolders.vdf
|
||||
libraryfolders_vdf_paths = [
|
||||
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
|
||||
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
|
||||
os.path.expanduser("~/.steam/root/config/libraryfolders.vdf"),
|
||||
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf") # Flatpak
|
||||
]
|
||||
|
||||
for vdf_path in libraryfolders_vdf_paths:
|
||||
if os.path.exists(vdf_path):
|
||||
# Extract the Steam path from the libraryfolders.vdf path
|
||||
steam_path = os.path.dirname(os.path.dirname(vdf_path))
|
||||
logger.info(f"Found Steam installation at: {steam_path}")
|
||||
return steam_path
|
||||
|
||||
preference = self.settings.get("steam_install_preference", STEAM_PREFERENCE_AUTO)
|
||||
install_type, install_root = resolve_preferred_steam_installation(preference=preference)
|
||||
if install_root:
|
||||
logger.info(
|
||||
"Selected Steam installation: %s (%s)",
|
||||
install_type,
|
||||
install_root,
|
||||
)
|
||||
return str(install_root)
|
||||
|
||||
logger.error("Steam installation not found")
|
||||
return None
|
||||
|
||||
@@ -376,4 +363,4 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ Config handler Proton path and version getters and auto-detect.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -10,6 +12,105 @@ logger = logging.getLogger(__name__)
|
||||
class ConfigProtonMixin:
|
||||
"""Mixin providing Proton path/version and auto-detect for ConfigHandler."""
|
||||
|
||||
@staticmethod
|
||||
def _is_usable_proton_path(proton_path: Optional[str]) -> bool:
|
||||
"""Return True when path looks like a valid Proton install directory."""
|
||||
if not proton_path:
|
||||
return False
|
||||
try:
|
||||
p = Path(str(proton_path)).expanduser()
|
||||
if not p.is_dir():
|
||||
return False
|
||||
# Valve Proton structure
|
||||
if (p / "dist" / "bin" / "wine").exists():
|
||||
return True
|
||||
# GE-Proton structure
|
||||
if (p / "files" / "bin" / "wine").exists():
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _best_proton_entry() -> Optional[Dict[str, Any]]:
|
||||
"""Get best detected Proton entry or None."""
|
||||
try:
|
||||
from .wine_utils import WineUtils
|
||||
return WineUtils.select_best_proton()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def normalize_proton_paths_on_boot(self) -> bool:
|
||||
"""
|
||||
Ensure stored Proton paths are valid at startup, repairing stale selections.
|
||||
|
||||
Rules:
|
||||
- If install proton path is missing/invalid, auto-detect next best and persist it.
|
||||
- If no compatible Proton exists, persist install path/version as null.
|
||||
- If game proton path is set and invalid, reset it to install proton (or null).
|
||||
|
||||
Returns:
|
||||
True if config values were changed and saved, False otherwise.
|
||||
"""
|
||||
changed = False
|
||||
|
||||
install_path = self.settings.get("proton_path")
|
||||
if install_path == "auto":
|
||||
install_path = None
|
||||
|
||||
install_valid = self._is_usable_proton_path(install_path)
|
||||
if not install_valid:
|
||||
best = self._best_proton_entry()
|
||||
if best:
|
||||
best_path = str(best["path"])
|
||||
best_name = str(best.get("name") or Path(best_path).name)
|
||||
if self.settings.get("proton_path") != best_path:
|
||||
self.settings["proton_path"] = best_path
|
||||
changed = True
|
||||
if self.settings.get("proton_version") != best_name:
|
||||
self.settings["proton_version"] = best_name
|
||||
changed = True
|
||||
logger.warning(
|
||||
"Install Proton path was missing/invalid; auto-selected %s (%s)",
|
||||
best_name,
|
||||
best_path,
|
||||
)
|
||||
else:
|
||||
if self.settings.get("proton_path") is not None:
|
||||
self.settings["proton_path"] = None
|
||||
changed = True
|
||||
if self.settings.get("proton_version") is not None:
|
||||
self.settings["proton_version"] = None
|
||||
changed = True
|
||||
logger.warning(
|
||||
"Install Proton path was missing/invalid and no compatible Proton was found"
|
||||
)
|
||||
else:
|
||||
# Keep proton_version in sync with existing valid path when missing/legacy.
|
||||
if not self.settings.get("proton_version"):
|
||||
self.settings["proton_version"] = Path(str(install_path)).name
|
||||
changed = True
|
||||
|
||||
effective_install = self.settings.get("proton_path")
|
||||
game_path = self.settings.get("game_proton_path")
|
||||
|
||||
# Legacy/placeholder values should not persist for runtime resolution.
|
||||
if game_path in ("same_as_install", "auto"):
|
||||
target = effective_install
|
||||
if self.settings.get("game_proton_path") != target:
|
||||
self.settings["game_proton_path"] = target
|
||||
changed = True
|
||||
elif game_path and not self._is_usable_proton_path(game_path):
|
||||
self.settings["game_proton_path"] = effective_install
|
||||
changed = True
|
||||
logger.warning(
|
||||
"Game Proton path was missing/invalid; reset to install Proton path"
|
||||
)
|
||||
|
||||
if changed:
|
||||
self.save_config()
|
||||
return changed
|
||||
|
||||
def get_proton_path(self):
|
||||
"""Retrieve the saved Install Proton path. Always reads fresh from disk."""
|
||||
try:
|
||||
|
||||
@@ -279,46 +279,56 @@ class ModlistMenuHandler:
|
||||
timestamp = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]"
|
||||
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
|
||||
|
||||
# Run the automated workflow
|
||||
result = prefix_service.run_working_workflow(
|
||||
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
|
||||
)
|
||||
|
||||
# Handle the result
|
||||
if isinstance(result, tuple) and len(result) == 4:
|
||||
if result[0] == "CONFLICT":
|
||||
# Handle conflict - ask user what to do
|
||||
conflicts = result[1]
|
||||
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
|
||||
for i, conflict in enumerate(conflicts, 1):
|
||||
print(f" {i}. Name: {conflict['name']}")
|
||||
print(f" Executable: {conflict['exe']}")
|
||||
print(f" Start Directory: {conflict['startdir']}")
|
||||
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
|
||||
print(" 1. Use existing shortcut (recommended)")
|
||||
print(" 2. Create new shortcut anyway")
|
||||
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
|
||||
if choice == "1":
|
||||
# Use existing shortcut
|
||||
existing_appid = conflicts[0].get('appid')
|
||||
if existing_appid:
|
||||
context = {
|
||||
"name": modlist_name,
|
||||
"appid": str(existing_appid),
|
||||
"path": mo2_dir,
|
||||
"manual_steps_completed": True,
|
||||
"resolution": None
|
||||
}
|
||||
return self.run_modlist_configuration_phase(context)
|
||||
elif choice == "2":
|
||||
# Create new shortcut - would need to handle this, but for now just fail
|
||||
print(f"{COLOR_ERROR}Creating new shortcut with same name not supported in this flow.{COLOR_RESET}")
|
||||
return True
|
||||
else:
|
||||
while True:
|
||||
result = prefix_service.run_working_workflow(
|
||||
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
|
||||
)
|
||||
|
||||
if isinstance(result, tuple) and len(result) == 4:
|
||||
if result[0] == "CONFLICT":
|
||||
conflicts = result[1]
|
||||
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
|
||||
for i, conflict in enumerate(conflicts, 1):
|
||||
print(f" {i}. Name: {conflict['name']}")
|
||||
print(f" Executable: {conflict['exe']}")
|
||||
print(f" Start Directory: {conflict['startdir']}")
|
||||
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
|
||||
print(" 1. Use existing shortcut (recommended)")
|
||||
print(" 2. Choose a different shortcut name")
|
||||
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
|
||||
if choice == "1":
|
||||
existing_appid = conflicts[0].get('appid')
|
||||
if existing_appid:
|
||||
context = {
|
||||
"name": modlist_name,
|
||||
"appid": str(existing_appid),
|
||||
"path": mo2_dir,
|
||||
"manual_steps_completed": True,
|
||||
"resolution": None
|
||||
}
|
||||
return self.run_modlist_configuration_phase(context)
|
||||
print(f"{COLOR_ERROR}Could not determine existing shortcut AppID.{COLOR_RESET}")
|
||||
return True
|
||||
if choice == "2":
|
||||
print("")
|
||||
print(f"{COLOR_PROMPT}Enter a different shortcut name for this modlist.{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}(Current conflicting name: {modlist_name}){COLOR_RESET}")
|
||||
new_name = input(f"{COLOR_PROMPT}New shortcut name (or 'q' to cancel): {COLOR_RESET}").strip()
|
||||
if new_name.lower() == 'q':
|
||||
print(f"{COLOR_INFO}Configuration cancelled by user.{COLOR_RESET}")
|
||||
return True
|
||||
if not new_name:
|
||||
print(f"{COLOR_ERROR}Name cannot be empty.{COLOR_RESET}")
|
||||
continue
|
||||
if new_name == modlist_name:
|
||||
print(f"{COLOR_ERROR}Please enter a different name to resolve the conflict.{COLOR_RESET}")
|
||||
continue
|
||||
modlist_name = new_name
|
||||
print(f"{COLOR_INFO}Retrying Steam setup with shortcut name: {modlist_name}{COLOR_RESET}")
|
||||
continue
|
||||
print(f"{COLOR_ERROR}Invalid choice.{COLOR_RESET}")
|
||||
return True
|
||||
else:
|
||||
# Success - get the results
|
||||
|
||||
success, prefix_path, appid_int, last_timestamp = result
|
||||
if success and appid_int:
|
||||
context = {
|
||||
@@ -330,10 +340,9 @@ class ModlistMenuHandler:
|
||||
}
|
||||
self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}")
|
||||
return self.run_modlist_configuration_phase(context)
|
||||
else:
|
||||
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
|
||||
return True
|
||||
else:
|
||||
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
|
||||
return True
|
||||
|
||||
# Unexpected result format
|
||||
print(f"{COLOR_ERROR}Automated workflow returned unexpected format.{COLOR_RESET}")
|
||||
self.logger.error(f"Unexpected result format from automated workflow: {result}")
|
||||
@@ -566,8 +575,18 @@ class ModlistMenuHandler:
|
||||
# Run modlist-specific post-install automation (e.g., VNV) before showing completion
|
||||
# Only in CLI mode - GUI handles this in install_modlist.py
|
||||
if not gui_mode:
|
||||
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
|
||||
from jackify.backend.services.vnv_integration_helper import (
|
||||
run_vnv_automation_if_applicable,
|
||||
should_offer_vnv_automation,
|
||||
)
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
from jackify.frontends.cli.commands.vnv_manual_downloads import (
|
||||
build_vnv_cli_manual_file_callback,
|
||||
create_vnv_cli_progress_callback,
|
||||
ensure_vnv_cli_manual_downloads,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
modlist_name = context.get('name', '')
|
||||
@@ -581,33 +600,46 @@ class ModlistMenuHandler:
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
return user_input in ("", "y", "yes")
|
||||
|
||||
def _manual_vnv_file(title: str, instructions: str):
|
||||
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
|
||||
print(instructions)
|
||||
try:
|
||||
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
if not file_input:
|
||||
return None
|
||||
selected = Path(file_input).expanduser().resolve()
|
||||
return selected if selected.exists() else None
|
||||
|
||||
automation_ran, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=modlist_name,
|
||||
modlist_install_location=modlist_path,
|
||||
game_root=None, # Will be auto-detected
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
progress_callback=lambda msg: print(msg),
|
||||
manual_file_callback=_manual_vnv_file,
|
||||
confirmation_callback=_confirm_vnv
|
||||
)
|
||||
if automation_ran and not error:
|
||||
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
|
||||
if error:
|
||||
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
|
||||
if should_offer_vnv_automation(modlist_name, modlist_path):
|
||||
game_paths = PathHandler().find_vanilla_game_paths()
|
||||
resolved_game_root = game_paths.get('Fallout New Vegas')
|
||||
vnv_service = VNVPostInstallService(
|
||||
modlist_install_location=modlist_path,
|
||||
game_root=resolved_game_root or modlist_path,
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
)
|
||||
completed = vnv_service.check_already_completed()
|
||||
all_vnv_steps_done = (
|
||||
completed['root_mods']
|
||||
and completed['4gb_patch']
|
||||
and completed['bsa_decompressed']
|
||||
)
|
||||
if all_vnv_steps_done:
|
||||
print(f"{COLOR_INFO}VNV post-install steps are already complete.{COLOR_RESET}")
|
||||
elif _confirm_vnv(vnv_service.get_automation_description()):
|
||||
if not ensure_vnv_cli_manual_downloads(vnv_service, output_callback=print):
|
||||
print(f"{COLOR_WARNING}VNV manual downloads were not completed. Skipping VNV automation.{COLOR_RESET}")
|
||||
else:
|
||||
progress_callback, close_progress = create_vnv_cli_progress_callback(print)
|
||||
try:
|
||||
automation_ran, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=modlist_name,
|
||||
modlist_install_location=modlist_path,
|
||||
game_root=None, # Will be auto-detected
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
progress_callback=progress_callback,
|
||||
manual_file_callback=build_vnv_cli_manual_file_callback(vnv_service, output_callback=print),
|
||||
confirmation_callback=lambda _description: True,
|
||||
)
|
||||
finally:
|
||||
close_progress()
|
||||
if automation_ran and not error:
|
||||
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
|
||||
if error:
|
||||
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
|
||||
else:
|
||||
print(f"{COLOR_INFO}VNV automation skipped by user.{COLOR_RESET}")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"VNV automation check skipped: {e}")
|
||||
# Not an error - just means VNV automation wasn't applicable
|
||||
|
||||
@@ -401,6 +401,18 @@ class ModlistConfigurationMixin:
|
||||
else:
|
||||
self.logger.warning("Could not set download_directory in ModOrganizer.ini")
|
||||
|
||||
# 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
|
||||
# engine-installed workflows skip edit_binary_working_paths.
|
||||
if not self.path_handler.align_home_path_basis(
|
||||
modlist_ini_path=modlist_ini_path_obj,
|
||||
modlist_dir_path=modlist_dir_path_obj,
|
||||
modlist_sdcard=self.modlist_sdcard,
|
||||
):
|
||||
self.logger.error("Failed to align home-path basis in ModOrganizer.ini. Configuration aborted.")
|
||||
self.logger.error("Failed to align /home path basis in ModOrganizer.ini.")
|
||||
return False
|
||||
|
||||
self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done")
|
||||
|
||||
# Step 9: Update Resolution Settings (if applicable)
|
||||
@@ -539,6 +551,9 @@ class ModlistConfigurationMixin:
|
||||
else:
|
||||
self.logger.debug("Step 13: No special launch options needed for this modlist type")
|
||||
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Finalizing post-install configuration")
|
||||
|
||||
# Do not call status_callback here, the final message is handled in menu_handler
|
||||
# if status_callback:
|
||||
# status_callback("Configuration completed successfully!")
|
||||
@@ -546,6 +561,8 @@ class ModlistConfigurationMixin:
|
||||
self.logger.info("Configuration steps completed successfully.")
|
||||
|
||||
# Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333)
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Re-applying final Windows compatibility settings")
|
||||
self._re_enforce_windows_10_mode()
|
||||
|
||||
return True # Return True on success
|
||||
@@ -581,4 +598,3 @@ class ModlistConfigurationMixin:
|
||||
else:
|
||||
self.selected_resolution = None
|
||||
self.logger.info("Resolution setup skipped by user.")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -352,10 +353,16 @@ class ModlistInstallCLITTWMixin:
|
||||
print(f"\nTTW has been installed to: {ttw_output_path}")
|
||||
print(f"TTW has been integrated into '{modlist_name}' (modlist.txt + plugins.txt updated).")
|
||||
print(f"The modlist '{modlist_name}' is now ready to use with TTW.")
|
||||
print(f"Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}")
|
||||
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
|
||||
else:
|
||||
print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}")
|
||||
print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}{COLOR_RESET}")
|
||||
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during TTW installation: {e}", exc_info=True)
|
||||
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}{COLOR_RESET}")
|
||||
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
|
||||
|
||||
@@ -28,6 +28,95 @@ SDCARD_PREFIX = '/run/media/mmcblk0p1/'
|
||||
class PathHandlerMO2Mixin:
|
||||
"""Mixin providing ModOrganizer.ini path updates and formatting."""
|
||||
|
||||
@staticmethod
|
||||
def _desired_home_basis_from_modlist_dir(modlist_dir_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Determine desired Linux home-path basis from modlist install directory.
|
||||
|
||||
Returns:
|
||||
"/var/home" when modlist dir is under /var/home,
|
||||
"/home" when modlist dir is under /home,
|
||||
None otherwise.
|
||||
"""
|
||||
try:
|
||||
posix = modlist_dir_path.as_posix()
|
||||
except Exception:
|
||||
posix = str(modlist_dir_path).replace("\\", "/")
|
||||
if posix.startswith("/var/home/"):
|
||||
return "/var/home"
|
||||
if posix.startswith("/home/"):
|
||||
return "/home"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_z_home_basis_in_line(line: str, desired_home_basis: str) -> str:
|
||||
"""
|
||||
Rewrite only Z:-drive /home -> /var/home path basis in a single INI line.
|
||||
|
||||
Preserves slash style (forward or backslash), and leaves D: paths untouched.
|
||||
"""
|
||||
if desired_home_basis == "/var/home":
|
||||
# Z:/home/... -> Z:/var/home/...
|
||||
# Z:\\home\\... -> Z:\\var\\home\\...
|
||||
return re.sub(r'([Zz]:[/\\]+)home([/\\]+)', r'\1var\2home\2', line)
|
||||
return line
|
||||
|
||||
def align_home_path_basis(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool) -> bool:
|
||||
"""
|
||||
Align gamePath/binary/workingDirectory home-path basis to modlist_dir_path.
|
||||
|
||||
This is a targeted post-processing step for Z: paths only:
|
||||
- If install path is /var/home/... then rewrite Z:/home/... to Z:/var/home/...
|
||||
- Otherwise do nothing.
|
||||
"""
|
||||
if modlist_sdcard:
|
||||
return True
|
||||
desired_home_basis = self._desired_home_basis_from_modlist_dir(modlist_dir_path)
|
||||
# This alignment pass is intentionally one-way:
|
||||
# only promote Z:/home -> Z:/var/home when install dir uses /var/home.
|
||||
if desired_home_basis != "/var/home":
|
||||
return True
|
||||
if not modlist_ini_path.is_file():
|
||||
logger.error(f"INI file {modlist_ini_path} does not exist for home-basis alignment")
|
||||
return False
|
||||
try:
|
||||
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
changed = 0
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if not (
|
||||
re.match(r'^\s*gamepath\s*=.*$', stripped, re.IGNORECASE)
|
||||
or re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE)
|
||||
or re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE)
|
||||
):
|
||||
continue
|
||||
rewritten = self._rewrite_z_home_basis_in_line(line, desired_home_basis)
|
||||
if rewritten != line:
|
||||
lines[i] = rewritten
|
||||
changed += 1
|
||||
|
||||
if changed > 0:
|
||||
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
logger.info(
|
||||
"Aligned ModOrganizer.ini home-path basis to %s for %d line(s): %s",
|
||||
desired_home_basis,
|
||||
changed,
|
||||
modlist_ini_path,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"No home-path basis alignment needed for %s (target %s)",
|
||||
modlist_ini_path,
|
||||
desired_home_basis,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error aligning home path basis in {modlist_ini_path}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _strip_sdcard_path_prefix(path_obj: Path) -> str:
|
||||
"""Removes SD card mount prefix. Returns path as POSIX-style string."""
|
||||
|
||||
@@ -12,6 +12,10 @@ from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import vdf
|
||||
from jackify.shared.steam_utils import (
|
||||
get_ordered_steam_roots,
|
||||
STEAM_PREFERENCE_AUTO,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,11 +27,7 @@ class PathHandlerSteamMixin:
|
||||
def find_steam_config_vdf() -> Optional[Path]:
|
||||
"""Finds the active Steam config.vdf file."""
|
||||
logger.debug("Searching for Steam config.vdf...")
|
||||
possible_steam_paths = [
|
||||
Path.home() / ".steam/steam",
|
||||
Path.home() / ".local/share/Steam",
|
||||
Path.home() / ".steam/root"
|
||||
]
|
||||
possible_steam_paths = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
|
||||
for steam_path in possible_steam_paths:
|
||||
potential_path = steam_path / "config/config.vdf"
|
||||
if potential_path.is_file():
|
||||
@@ -40,10 +40,9 @@ class PathHandlerSteamMixin:
|
||||
def find_steam_library() -> Optional[Path]:
|
||||
"""Find the primary Steam library common directory containing games."""
|
||||
logger.debug("Attempting to find Steam library...")
|
||||
ordered_roots = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
|
||||
libraryfolders_vdf_paths = [
|
||||
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
|
||||
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
|
||||
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf"),
|
||||
str(root / "config" / "libraryfolders.vdf") for root in ordered_roots
|
||||
]
|
||||
for path in libraryfolders_vdf_paths:
|
||||
if os.path.exists(path):
|
||||
@@ -92,14 +91,11 @@ class PathHandlerSteamMixin:
|
||||
logger.info(f"Using Steam library common path: {library_paths[0]}")
|
||||
return library_paths[0]
|
||||
logger.debug("No valid common paths found in VDF, checking default location...")
|
||||
default_common_path = Path.home() / ".steam/steam/steamapps/common"
|
||||
if default_common_path.is_dir():
|
||||
logger.info(f"Using default Steam library common path: {default_common_path}")
|
||||
return default_common_path
|
||||
default_common_path_local = Path.home() / ".local/share/Steam/steamapps/common"
|
||||
if default_common_path_local.is_dir():
|
||||
logger.info(f"Using default local Steam library common path: {default_common_path_local}")
|
||||
return default_common_path_local
|
||||
for root in ordered_roots:
|
||||
default_common_path = root / "steamapps" / "common"
|
||||
if default_common_path.is_dir():
|
||||
logger.info(f"Using default Steam library common path: {default_common_path}")
|
||||
return default_common_path
|
||||
logger.error("No valid Steam library common path found in VDF or default locations.")
|
||||
return None
|
||||
except Exception as e:
|
||||
@@ -181,12 +177,8 @@ class PathHandlerSteamMixin:
|
||||
def get_all_steam_library_paths() -> List[Path]:
|
||||
"""Finds all Steam library paths listed in all known libraryfolders.vdf files (including Flatpak)."""
|
||||
logger.info("[DEBUG] Searching for all Steam libraryfolders.vdf files...")
|
||||
vdf_paths = [
|
||||
Path.home() / ".steam/steam/config/libraryfolders.vdf",
|
||||
Path.home() / ".local/share/Steam/config/libraryfolders.vdf",
|
||||
Path.home() / ".steam/root/config/libraryfolders.vdf",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf",
|
||||
]
|
||||
ordered_roots = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
|
||||
vdf_paths = [root / "config" / "libraryfolders.vdf" for root in ordered_roots]
|
||||
library_paths = set()
|
||||
for vdf_path in vdf_paths:
|
||||
if vdf_path.is_file():
|
||||
|
||||
@@ -6,6 +6,7 @@ import resource
|
||||
import sys
|
||||
import shutil
|
||||
import logging
|
||||
import threading
|
||||
|
||||
def get_safe_python_executable():
|
||||
"""
|
||||
@@ -154,7 +155,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):
|
||||
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False):
|
||||
self.cmd = cmd
|
||||
# Default to cleaned environment if None to prevent AppImage variable inheritance
|
||||
if env is None:
|
||||
@@ -165,14 +166,18 @@ class ProcessManager:
|
||||
self.text = text
|
||||
self.bufsize = bufsize
|
||||
self.separate_stderr = separate_stderr
|
||||
self.enable_stdin = enable_stdin
|
||||
self.proc = None
|
||||
self.process_group_pid = None
|
||||
self._stdin_lock = threading.Lock()
|
||||
self._start_process()
|
||||
|
||||
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,
|
||||
@@ -190,31 +195,45 @@ class ProcessManager:
|
||||
cleanup_attempts = 0
|
||||
try:
|
||||
if self.proc:
|
||||
# Terminate process group first so child tools don't survive parent exit.
|
||||
if self.process_group_pid:
|
||||
try:
|
||||
os.killpg(self.process_group_pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_terminate)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.proc.kill()
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_kill)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
self.proc.wait(timeout=timeout_terminate)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# Kill entire process group (catches 7zz and other child processes)
|
||||
|
||||
# Escalate to SIGKILL for stubborn children/process group.
|
||||
if self.process_group_pid:
|
||||
try:
|
||||
os.killpg(self.process_group_pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
# Last resort: pkill by command name
|
||||
|
||||
try:
|
||||
self.proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_kill)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Last resort: pkill by command name (kept bounded).
|
||||
while cleanup_attempts < max_cleanup_attempts:
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True)
|
||||
@@ -224,7 +243,7 @@ class ProcessManager:
|
||||
finally:
|
||||
# Always close pipes — unblocks threads blocked on read(1) or iterating stderr
|
||||
if self.proc:
|
||||
for pipe in (self.proc.stdout, self.proc.stderr):
|
||||
for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
|
||||
if pipe:
|
||||
try:
|
||||
pipe.close()
|
||||
@@ -250,4 +269,20 @@ class ProcessManager:
|
||||
return self.proc.stdout.read(1)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return None
|
||||
return None
|
||||
|
||||
def write_stdin(self, line: str) -> bool:
|
||||
"""
|
||||
Write a line to the process stdin. Thread-safe.
|
||||
Returns True on success, False if stdin is not available or process is gone.
|
||||
"""
|
||||
if not self.enable_stdin or not self.proc or not self.proc.stdin:
|
||||
return False
|
||||
with self._stdin_lock:
|
||||
try:
|
||||
payload = line if line.endswith('\n') else line + '\n'
|
||||
self.proc.stdin.write(payload.encode())
|
||||
self.proc.stdin.flush()
|
||||
return True
|
||||
except (OSError, BrokenPipeError):
|
||||
return False
|
||||
|
||||
@@ -64,17 +64,29 @@ class TTWInstallerBackendMixin:
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1, universal_newlines=True
|
||||
)
|
||||
error_context: list = []
|
||||
capturing_explanation = False
|
||||
if process.stdout:
|
||||
for line in process.stdout:
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
self.logger.info("TTW_Linux_Installer: %s", line)
|
||||
lower = line.lower()
|
||||
if 'failed' in lower or 'cannot continue' in lower or 'error:' in lower:
|
||||
error_context.append(line.strip())
|
||||
capturing_explanation = True
|
||||
elif capturing_explanation and line.startswith(' '):
|
||||
error_context.append(line.strip())
|
||||
else:
|
||||
capturing_explanation = False
|
||||
process.wait()
|
||||
ret = process.returncode
|
||||
if ret == 0:
|
||||
self.logger.info("TTW installation completed successfully.")
|
||||
return True, "TTW installation completed successfully!"
|
||||
self.logger.error("TTW installation process returned non-zero exit code: %s", ret)
|
||||
if error_context:
|
||||
return False, "TTW installation failed:\n" + "\n".join(error_context)
|
||||
return False, f"TTW installation failed with exit code {ret}"
|
||||
except Exception as e:
|
||||
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)
|
||||
@@ -210,6 +222,8 @@ class TTWInstallerBackendMixin:
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1, universal_newlines=True
|
||||
)
|
||||
error_context: list = []
|
||||
capturing_explanation = False
|
||||
if process.stdout:
|
||||
for line in process.stdout:
|
||||
line = line.rstrip()
|
||||
@@ -217,12 +231,22 @@ class TTWInstallerBackendMixin:
|
||||
self.logger.info("TTW_Linux_Installer: %s", line)
|
||||
if output_callback:
|
||||
output_callback(line)
|
||||
lower = line.lower()
|
||||
if 'failed' in lower or 'cannot continue' in lower or 'error:' in lower:
|
||||
error_context.append(line.strip())
|
||||
capturing_explanation = True
|
||||
elif capturing_explanation and line.startswith(' '):
|
||||
error_context.append(line.strip())
|
||||
else:
|
||||
capturing_explanation = False
|
||||
process.wait()
|
||||
ret = process.returncode
|
||||
if ret == 0:
|
||||
self.logger.info("TTW installation completed successfully.")
|
||||
return True, "TTW installation completed successfully!"
|
||||
self.logger.error("TTW installation process returned non-zero exit code: %s", ret)
|
||||
if error_context:
|
||||
return False, "TTW installation failed:\n" + "\n".join(error_context)
|
||||
return False, f"TTW installation failed with exit code {ret}"
|
||||
except Exception as e:
|
||||
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)
|
||||
|
||||
Reference in New Issue
Block a user