mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 11:37:01 +01:00
Sync from development - prepare for v0.1.7
This commit is contained in:
25
CHANGELOG.md
25
CHANGELOG.md
@@ -1,5 +1,30 @@
|
|||||||
# Jackify Changelog
|
# Jackify Changelog
|
||||||
|
|
||||||
|
## v0.1.7 - TTW Automation & Bug Fixes
|
||||||
|
**Release Date:** November 1, 2025
|
||||||
|
|
||||||
|
### Major Features
|
||||||
|
- **TTW (Tale of Two Wastelands) Installation and Automation**
|
||||||
|
- TTW Installation function using Hoolamike application - https://github.com/Niedzwiedzw/hoolamike
|
||||||
|
- Automated workflow for TTW installation and integration into FNV modlists, where possible
|
||||||
|
- Automatic detection of TTW-compatible modlists
|
||||||
|
- User prompt after modlist installation with option to install TTW
|
||||||
|
- Automated integration: file copying, load order updates, modlist.txt updates
|
||||||
|
- Available in both CLI and GUI workflows
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Registry UTF-8 Decode Error**: Fixed crash during dotnet4.x installation when Wine outputs binary data
|
||||||
|
- **Python 3.10 Compatibility**: Fixed startup crash on Python 3.10 systems
|
||||||
|
- **TTW Steam Deck Layout**: Fixed window sizing issues on Steam Deck when entering/exiting TTW screen
|
||||||
|
- **TTW Integration Status**: Added visible status banner updates during modlist integration for collapsed mode
|
||||||
|
- **TTW Accidental Input Protection**: Added 3-second countdown to TTW installation prompt to prevent accidental dismissal
|
||||||
|
- **Settings Persistence**: Settings changes now persist correctly across workflows
|
||||||
|
- **Steam Deck Keyboard Input**: Fixed keyboard input failure on Steam Deck
|
||||||
|
- **Application Close Crash**: Fixed crash when closing application on Steam Deck
|
||||||
|
- **Winetricks Diagnostics**: Enhanced error detection with automatic fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.1.6.6 - AppImage Bundling Fix
|
## v0.1.6.6 - AppImage Bundling Fix
|
||||||
**Release Date:** October 29, 2025
|
**Release Date:** October 29, 2025
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
|||||||
Wabbajack modlists natively on Linux systems.
|
Wabbajack modlists natively on Linux systems.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.6.6"
|
__version__ = "0.1.7"
|
||||||
|
|||||||
3
jackify/backend/data/__init__.py
Normal file
3
jackify/backend/data/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Data package for static configuration and reference data.
|
||||||
|
"""
|
||||||
46
jackify/backend/data/ttw_compatible_modlists.py
Normal file
46
jackify/backend/data/ttw_compatible_modlists.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
TTW-Compatible Modlists Configuration
|
||||||
|
|
||||||
|
Defines which Fallout New Vegas modlists support Tale of Two Wastelands.
|
||||||
|
This whitelist determines when Jackify should offer TTW installation after
|
||||||
|
a successful modlist installation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TTW_COMPATIBLE_MODLISTS = {
|
||||||
|
# Exact modlist names that support/require TTW
|
||||||
|
"exact_matches": [
|
||||||
|
"Begin Again",
|
||||||
|
"Uranium Fever",
|
||||||
|
"The Badlands",
|
||||||
|
"Wild Card TTW",
|
||||||
|
],
|
||||||
|
|
||||||
|
# Pattern matching for modlist names (regex)
|
||||||
|
"patterns": [
|
||||||
|
r".*TTW.*", # Any modlist with TTW in name
|
||||||
|
r".*Tale.*Two.*Wastelands.*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_ttw_compatible(modlist_name: str) -> bool:
|
||||||
|
"""Check if modlist name matches TTW compatibility criteria
|
||||||
|
|
||||||
|
Args:
|
||||||
|
modlist_name: Name of the modlist to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if modlist is TTW-compatible, False otherwise
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Check exact matches
|
||||||
|
if modlist_name in TTW_COMPATIBLE_MODLISTS['exact_matches']:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check pattern matches
|
||||||
|
for pattern in TTW_COMPATIBLE_MODLISTS['patterns']:
|
||||||
|
if re.match(pattern, modlist_name, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -20,10 +20,23 @@ logger = logging.getLogger(__name__)
|
|||||||
class ConfigHandler:
|
class ConfigHandler:
|
||||||
"""
|
"""
|
||||||
Handles application configuration and settings
|
Handles application configuration and settings
|
||||||
|
Singleton pattern ensures all code shares the same instance
|
||||||
"""
|
"""
|
||||||
|
_instance = None
|
||||||
|
_initialized = False
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(ConfigHandler, cls).__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize configuration handler with default settings"""
|
"""Initialize configuration handler with default settings"""
|
||||||
|
# Only initialize once (singleton pattern)
|
||||||
|
if ConfigHandler._initialized:
|
||||||
|
return
|
||||||
|
ConfigHandler._initialized = True
|
||||||
|
|
||||||
self.config_dir = os.path.expanduser("~/.config/jackify")
|
self.config_dir = os.path.expanduser("~/.config/jackify")
|
||||||
self.config_file = os.path.join(self.config_dir, "config.json")
|
self.config_file = os.path.join(self.config_dir, "config.json")
|
||||||
self.settings = {
|
self.settings = {
|
||||||
@@ -50,8 +63,9 @@ class ConfigHandler:
|
|||||||
if not self.settings["steam_path"]:
|
if not self.settings["steam_path"]:
|
||||||
self.settings["steam_path"] = self._detect_steam_path()
|
self.settings["steam_path"] = self._detect_steam_path()
|
||||||
|
|
||||||
# Auto-detect and set Proton version on first run
|
# Auto-detect and set Proton version ONLY on first run (config file doesn't exist)
|
||||||
if not self.settings.get("proton_path"):
|
# 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()
|
self._auto_detect_proton()
|
||||||
|
|
||||||
# If jackify_data_dir is not set, initialize it to default
|
# If jackify_data_dir is not set, initialize it to default
|
||||||
@@ -114,6 +128,10 @@ class ConfigHandler:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading configuration: {e}")
|
logger.error(f"Error loading configuration: {e}")
|
||||||
|
|
||||||
|
def reload_config(self):
|
||||||
|
"""Reload configuration from disk to pick up external changes"""
|
||||||
|
self._load_config()
|
||||||
|
|
||||||
def _create_config_dir(self):
|
def _create_config_dir(self):
|
||||||
"""Create configuration directory if it doesn't exist"""
|
"""Create configuration directory if it doesn't exist"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from .filesystem_handler import FileSystemHandler
|
|||||||
from .config_handler import ConfigHandler
|
from .config_handler import ConfigHandler
|
||||||
# Import color constants needed for print statements in this module
|
# Import color constants needed for print statements in this module
|
||||||
from .ui_colors import COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING, COLOR_RESET, COLOR_INFO, COLOR_PROMPT, COLOR_SELECTION
|
from .ui_colors import COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING, COLOR_RESET, COLOR_INFO, COLOR_PROMPT, COLOR_SELECTION
|
||||||
# Standard logging (no file handler) - LoggingHandler import removed
|
from .logging_handler import LoggingHandler
|
||||||
from .status_utils import show_status, clear_status
|
from .status_utils import show_status, clear_status
|
||||||
from .subprocess_utils import get_clean_subprocess_env
|
from .subprocess_utils import get_clean_subprocess_env
|
||||||
|
|
||||||
@@ -55,8 +55,10 @@ class HoolamikeHandler:
|
|||||||
self.filesystem_handler = filesystem_handler
|
self.filesystem_handler = filesystem_handler
|
||||||
self.config_handler = config_handler
|
self.config_handler = config_handler
|
||||||
self.menu_handler = menu_handler
|
self.menu_handler = menu_handler
|
||||||
# Use standard logging (no file handler)
|
# Set up dedicated log file for TTW operations
|
||||||
self.logger = logging.getLogger(__name__)
|
logging_handler = LoggingHandler()
|
||||||
|
logging_handler.rotate_log_for_logger('ttw-install', 'TTW_Install_workflow.log')
|
||||||
|
self.logger = logging_handler.setup_logger('ttw-install', 'TTW_Install_workflow.log')
|
||||||
|
|
||||||
# --- Discovered/Managed State ---
|
# --- Discovered/Managed State ---
|
||||||
self.game_install_paths: Dict[str, Path] = {}
|
self.game_install_paths: Dict[str, Path] = {}
|
||||||
@@ -213,7 +215,7 @@ class HoolamikeHandler:
|
|||||||
if not self.hoolamike_config.get("games"):
|
if not self.hoolamike_config.get("games"):
|
||||||
f.write("# No games were detected by Jackify. Add game paths manually if needed.\n")
|
f.write("# No games were detected by Jackify. Add game paths manually if needed.\n")
|
||||||
# Dump the actual YAML
|
# Dump the actual YAML
|
||||||
yaml.dump(self.hoolamike_config, f, default_flow_style=False, sort_keys=False)
|
yaml.dump(self.hoolamike_config, f, default_flow_style=False, sort_keys=False, width=float('inf'))
|
||||||
self.logger.info("Configuration saved successfully.")
|
self.logger.info("Configuration saved successfully.")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -224,6 +226,9 @@ class HoolamikeHandler:
|
|||||||
"""Execute all discovery steps."""
|
"""Execute all discovery steps."""
|
||||||
self.logger.info("Starting Hoolamike feature discovery phase...")
|
self.logger.info("Starting Hoolamike feature discovery phase...")
|
||||||
|
|
||||||
|
# Check if Hoolamike is installed
|
||||||
|
self._check_hoolamike_installation()
|
||||||
|
|
||||||
# Detect game paths and update internal state + config
|
# Detect game paths and update internal state + config
|
||||||
self._detect_and_update_game_paths()
|
self._detect_and_update_game_paths()
|
||||||
|
|
||||||
@@ -253,11 +258,132 @@ class HoolamikeHandler:
|
|||||||
self.hoolamike_config["games"][formatted_name] = {"root_directory": str(detected_path)}
|
self.hoolamike_config["games"][formatted_name] = {"root_directory": str(detected_path)}
|
||||||
|
|
||||||
self.logger.info(f"Updated config with {len(detected_paths)} game paths using correct naming format (no spaces)")
|
self.logger.info(f"Updated config with {len(detected_paths)} game paths using correct naming format (no spaces)")
|
||||||
|
|
||||||
|
# Save the updated config to disk so Hoolamike can read it
|
||||||
|
if detected_paths:
|
||||||
|
self.logger.info("Saving updated game paths to hoolamike.yaml")
|
||||||
|
self.save_hoolamike_config()
|
||||||
else:
|
else:
|
||||||
self.logger.warning("Cannot update game paths in config because config is not loaded.")
|
self.logger.warning("Cannot update game paths in config because config is not loaded.")
|
||||||
|
|
||||||
# --- Methods for Hoolamike Tasks (To be implemented later) ---
|
# --- Methods for Hoolamike Tasks ---
|
||||||
# TODO: Update these methods to accept necessary parameters and update/save config
|
# GUI-safe, non-interactive installer used by Install TTW screen
|
||||||
|
def install_hoolamike(self, install_dir: Optional[Path] = None) -> tuple[bool, str]:
|
||||||
|
"""Non-interactive install/update of Hoolamike for GUI usage.
|
||||||
|
|
||||||
|
Downloads the latest Linux x86_64 release from GitHub, extracts it to the
|
||||||
|
Jackify-managed directory (~/Jackify/Hoolamike by default or provided install_dir),
|
||||||
|
sets executable permissions, and saves the install path to Jackify config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._ensure_hoolamike_dirs_exist()
|
||||||
|
# Determine target install directory
|
||||||
|
target_dir = Path(install_dir) if install_dir else self.hoolamike_app_install_path
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Fetch latest release info
|
||||||
|
release_url = "https://api.github.com/repos/Niedzwiedzw/hoolamike/releases/latest"
|
||||||
|
self.logger.info(f"Fetching latest Hoolamike release info from {release_url}")
|
||||||
|
resp = requests.get(release_url, timeout=15, verify=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
release_tag = data.get("tag_name") or data.get("name")
|
||||||
|
|
||||||
|
linux_asset = None
|
||||||
|
for asset in data.get("assets", []):
|
||||||
|
name = asset.get("name", "").lower()
|
||||||
|
if "linux" in name and (name.endswith(".tar.gz") or name.endswith(".tgz") or name.endswith(".zip")) and ("x86_64" in name or "amd64" in name):
|
||||||
|
linux_asset = asset
|
||||||
|
break
|
||||||
|
|
||||||
|
if not linux_asset:
|
||||||
|
return False, "No suitable Linux x86_64 Hoolamike asset found in latest release"
|
||||||
|
|
||||||
|
download_url = linux_asset.get("browser_download_url")
|
||||||
|
asset_name = linux_asset.get("name")
|
||||||
|
if not download_url or not asset_name:
|
||||||
|
return False, "Latest release is missing required asset metadata"
|
||||||
|
|
||||||
|
# Download to target directory
|
||||||
|
temp_path = target_dir / asset_name
|
||||||
|
if not self.filesystem_handler.download_file(download_url, temp_path, overwrite=True, quiet=True):
|
||||||
|
return False, "Failed to download Hoolamike asset"
|
||||||
|
|
||||||
|
# Extract
|
||||||
|
try:
|
||||||
|
if asset_name.lower().endswith((".tar.gz", ".tgz")):
|
||||||
|
with tarfile.open(temp_path, "r:*") as tar:
|
||||||
|
tar.extractall(path=target_dir)
|
||||||
|
elif asset_name.lower().endswith(".zip"):
|
||||||
|
with zipfile.ZipFile(temp_path, "r") as zf:
|
||||||
|
zf.extractall(target_dir)
|
||||||
|
else:
|
||||||
|
return False, f"Unknown archive format: {asset_name}"
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
temp_path.unlink(missing_ok=True) # cleanup
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Ensure executable bit on binary
|
||||||
|
exe_path = target_dir / HOOLAMIKE_EXECUTABLE_NAME
|
||||||
|
if not exe_path.is_file():
|
||||||
|
# Some archives may include a subfolder; try to locate the binary
|
||||||
|
for p in target_dir.rglob(HOOLAMIKE_EXECUTABLE_NAME):
|
||||||
|
if p.is_file():
|
||||||
|
exe_path = p
|
||||||
|
break
|
||||||
|
if not exe_path.is_file():
|
||||||
|
return False, "Hoolamike binary not found after extraction"
|
||||||
|
try:
|
||||||
|
os.chmod(exe_path, 0o755)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Failed to chmod +x on {exe_path}: {e}")
|
||||||
|
|
||||||
|
# Mark installed and persist path
|
||||||
|
self.hoolamike_app_install_path = target_dir
|
||||||
|
self.hoolamike_executable_path = exe_path
|
||||||
|
self.hoolamike_installed = True
|
||||||
|
self.config_handler.set('hoolamike_install_path', str(target_dir))
|
||||||
|
if release_tag:
|
||||||
|
self.config_handler.set('hoolamike_version', str(release_tag))
|
||||||
|
self.config_handler.save_config()
|
||||||
|
|
||||||
|
return True, f"Hoolamike installed at {target_dir}"
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error("Hoolamike installation failed", exc_info=True)
|
||||||
|
return False, f"Error installing Hoolamike: {e}"
|
||||||
|
|
||||||
|
def get_installed_hoolamike_version(self) -> Optional[str]:
|
||||||
|
"""Return the installed Hoolamike version stored in Jackify config, if any."""
|
||||||
|
try:
|
||||||
|
v = self.config_handler.get('hoolamike_version')
|
||||||
|
return str(v) if v else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_hoolamike_update_available(self) -> tuple[bool, Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Check GitHub for the latest Hoolamike release and compare with installed version.
|
||||||
|
Returns (update_available, installed_version, latest_version).
|
||||||
|
"""
|
||||||
|
installed = self.get_installed_hoolamike_version()
|
||||||
|
try:
|
||||||
|
release_url = "https://api.github.com/repos/Niedzwiedzw/hoolamike/releases/latest"
|
||||||
|
resp = requests.get(release_url, timeout=10, verify=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
latest = resp.json().get('tag_name') or resp.json().get('name')
|
||||||
|
if not latest:
|
||||||
|
return (False, installed, None)
|
||||||
|
if not installed:
|
||||||
|
# No version recorded but installed may exist; treat as update available
|
||||||
|
return (True, None, latest)
|
||||||
|
return (installed != str(latest), installed, str(latest))
|
||||||
|
except Exception:
|
||||||
|
return (False, installed, None)
|
||||||
|
|
||||||
def install_update_hoolamike(self, context=None) -> bool:
|
def install_update_hoolamike(self, context=None) -> bool:
|
||||||
"""Install or update Hoolamike application.
|
"""Install or update Hoolamike application.
|
||||||
@@ -654,18 +780,89 @@ class HoolamikeHandler:
|
|||||||
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
|
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def install_ttw(self, ttw_mpi_path=None, ttw_output_path=None, context=None):
|
def install_ttw_backend(self, ttw_mpi_path, ttw_output_path):
|
||||||
"""Install Tale of Two Wastelands (TTW) using Hoolamike.
|
"""Clean backend function for TTW installation - no user interaction.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ttw_mpi_path: Path to the TTW installer .mpi file
|
ttw_mpi_path: Path to the TTW installer .mpi file (required)
|
||||||
ttw_output_path: Target installation directory for TTW
|
ttw_output_path: Target installation directory for TTW (required)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success: bool, message: str)
|
||||||
|
"""
|
||||||
|
self.logger.info(f"Starting Tale of Two Wastelands installation via Hoolamike")
|
||||||
|
|
||||||
|
# Validate required parameters
|
||||||
|
if not ttw_mpi_path or not ttw_output_path:
|
||||||
|
return False, "Missing required parameters: ttw_mpi_path and ttw_output_path are required"
|
||||||
|
|
||||||
|
# Convert to Path objects
|
||||||
|
ttw_mpi_path = Path(ttw_mpi_path)
|
||||||
|
ttw_output_path = Path(ttw_output_path)
|
||||||
|
|
||||||
|
# Validate paths exist
|
||||||
|
if not ttw_mpi_path.exists():
|
||||||
|
return False, f"TTW .mpi file not found: {ttw_mpi_path}"
|
||||||
|
|
||||||
|
if not ttw_output_path.exists():
|
||||||
|
try:
|
||||||
|
ttw_output_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Failed to create output directory: {e}"
|
||||||
|
|
||||||
|
# Check Hoolamike installation
|
||||||
|
self._check_hoolamike_installation()
|
||||||
|
|
||||||
|
# Ensure config is loaded
|
||||||
|
if self.hoolamike_config is None:
|
||||||
|
loaded = self._load_hoolamike_config()
|
||||||
|
if not loaded or self.hoolamike_config is None:
|
||||||
|
self.logger.error("Failed to load or generate hoolamike.yaml configuration.")
|
||||||
|
return False, "Failed to load or generate Hoolamike configuration"
|
||||||
|
|
||||||
|
# Verify required games are detected
|
||||||
|
required_games = ['Fallout 3', 'Fallout New Vegas']
|
||||||
|
detected_games = self.path_handler.find_vanilla_game_paths()
|
||||||
|
missing_games = [game for game in required_games if game not in detected_games]
|
||||||
|
if missing_games:
|
||||||
|
self.logger.error(f"Missing required games for TTW installation: {', '.join(missing_games)}")
|
||||||
|
return False, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas."
|
||||||
|
|
||||||
|
# Update TTW configuration
|
||||||
|
self._update_hoolamike_config_for_ttw(ttw_mpi_path, ttw_output_path)
|
||||||
|
if not self.save_hoolamike_config():
|
||||||
|
self.logger.error("Failed to save hoolamike.yaml configuration.")
|
||||||
|
return False, "Failed to save Hoolamike configuration"
|
||||||
|
|
||||||
|
# Construct and execute command
|
||||||
|
cmd = [
|
||||||
|
str(self.hoolamike_executable_path),
|
||||||
|
"tale-of-two-wastelands"
|
||||||
|
]
|
||||||
|
self.logger.info(f"Executing Hoolamike command: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ret = subprocess.call(cmd, cwd=str(self.hoolamike_app_install_path), env=get_clean_subprocess_env())
|
||||||
|
if ret == 0:
|
||||||
|
self.logger.info("TTW installation completed successfully.")
|
||||||
|
return True, "TTW installation completed successfully!"
|
||||||
|
else:
|
||||||
|
self.logger.error(f"TTW installation process returned non-zero exit code: {ret}")
|
||||||
|
return False, f"TTW installation failed with exit code {ret}"
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error executing Hoolamike TTW installation: {e}", exc_info=True)
|
||||||
|
return False, f"Error executing Hoolamike TTW installation: {e}"
|
||||||
|
|
||||||
|
def install_ttw(self, ttw_mpi_path=None, ttw_output_path=None, context=None):
|
||||||
|
"""CLI interface for TTW installation - handles user interaction and calls backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ttw_mpi_path: Path to the TTW installer .mpi file (optional for CLI)
|
||||||
|
ttw_output_path: Target installation directory for TTW (optional for CLI)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if successful, False otherwise
|
bool: True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
self.logger.info(f"Starting Tale of Two Wastelands installation via Hoolamike")
|
|
||||||
self._check_hoolamike_installation()
|
|
||||||
menu = self.menu_handler
|
menu = self.menu_handler
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'='*60}")
|
||||||
print(f"{COLOR_INFO}Hoolamike: Tale of Two Wastelands Installation{COLOR_RESET}")
|
print(f"{COLOR_INFO}Hoolamike: Tale of Two Wastelands Installation{COLOR_RESET}")
|
||||||
@@ -676,25 +873,18 @@ class HoolamikeHandler:
|
|||||||
print(f" • You must provide the path to your TTW .mpi installer file.")
|
print(f" • You must provide the path to your TTW .mpi installer file.")
|
||||||
print(f" • You must select an output directory for the TTW install.\n")
|
print(f" • You must select an output directory for the TTW install.\n")
|
||||||
|
|
||||||
# Ensure config is loaded
|
# If parameters provided, use them directly
|
||||||
if self.hoolamike_config is None:
|
if ttw_mpi_path and ttw_output_path:
|
||||||
loaded = self._load_hoolamike_config()
|
print(f"{COLOR_INFO}Using provided parameters:{COLOR_RESET}")
|
||||||
if not loaded or self.hoolamike_config is None:
|
print(f"- TTW .mpi file: {ttw_mpi_path}")
|
||||||
self.logger.error("Failed to load or generate hoolamike.yaml configuration.")
|
print(f"- Output directory: {ttw_output_path}")
|
||||||
print(f"{COLOR_ERROR}Error: Could not load or generate Hoolamike configuration. Aborting TTW install.{COLOR_RESET}")
|
print(f"{COLOR_WARNING}Proceed with these settings and start TTW installation? (This can take MANY HOURS){COLOR_RESET}")
|
||||||
|
confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower()
|
||||||
|
if confirm and not confirm.startswith('y'):
|
||||||
|
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
|
||||||
return False
|
return False
|
||||||
|
else:
|
||||||
# Verify required games are in configuration
|
# Interactive mode - collect user input
|
||||||
required_games = ['Fallout 3', 'Fallout New Vegas']
|
|
||||||
detected_games = self.path_handler.find_vanilla_game_paths()
|
|
||||||
missing_games = [game for game in required_games if game not in detected_games]
|
|
||||||
if missing_games:
|
|
||||||
self.logger.error(f"Missing required games for TTW installation: {', '.join(missing_games)}")
|
|
||||||
print(f"{COLOR_ERROR}Error: The following required games were not found: {', '.join(missing_games)}{COLOR_RESET}")
|
|
||||||
print("TTW requires both Fallout 3 and Fallout New Vegas to be installed.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Prompt for TTW .mpi file
|
|
||||||
print(f"{COLOR_INFO}Please provide the path to your TTW .mpi installer file.{COLOR_RESET}")
|
print(f"{COLOR_INFO}Please provide the path to your TTW .mpi installer file.{COLOR_RESET}")
|
||||||
print(f"You can download this from: {COLOR_INFO}https://mod.pub/ttw/133/files{COLOR_RESET}")
|
print(f"You can download this from: {COLOR_INFO}https://mod.pub/ttw/133/files{COLOR_RESET}")
|
||||||
print(f"(Extract the .mpi file from the downloaded archive.)\n")
|
print(f"(Extract the .mpi file from the downloaded archive.)\n")
|
||||||
@@ -733,15 +923,11 @@ class HoolamikeHandler:
|
|||||||
ttw_output_path = None
|
ttw_output_path = None
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# --- Summary & Confirmation ---
|
# Summary & Confirmation
|
||||||
print(f"\n{'-'*60}")
|
print(f"\n{'-'*60}")
|
||||||
print(f"{COLOR_INFO}Summary of configuration:{COLOR_RESET}")
|
print(f"{COLOR_INFO}Summary of configuration:{COLOR_RESET}")
|
||||||
print(f"- TTW .mpi file: {ttw_mpi_path}")
|
print(f"- TTW .mpi file: {ttw_mpi_path}")
|
||||||
print(f"- Output directory: {ttw_output_path}")
|
print(f"- Output directory: {ttw_output_path}")
|
||||||
print("- Games:")
|
|
||||||
for game in required_games:
|
|
||||||
found = detected_games.get(game)
|
|
||||||
print(f" {game}: {found if found else 'Not Found'}")
|
|
||||||
print(f"{'-'*60}")
|
print(f"{'-'*60}")
|
||||||
print(f"{COLOR_WARNING}Proceed with these settings and start TTW installation? (This can take MANY HOURS){COLOR_RESET}")
|
print(f"{COLOR_WARNING}Proceed with these settings and start TTW installation? (This can take MANY HOURS){COLOR_RESET}")
|
||||||
confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower()
|
confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower()
|
||||||
@@ -749,50 +935,28 @@ class HoolamikeHandler:
|
|||||||
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
|
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# --- Always re-detect games before updating config ---
|
# Call the clean backend function
|
||||||
detected_games = self.path_handler.find_vanilla_game_paths()
|
success, message = self.install_ttw_backend(ttw_mpi_path, ttw_output_path)
|
||||||
if not detected_games:
|
|
||||||
print(f"{COLOR_ERROR}No supported games were detected on your system. TTW requires Fallout 3 and Fallout New Vegas to be installed.{COLOR_RESET}")
|
|
||||||
return False
|
|
||||||
# Update the games section with correct keys
|
|
||||||
if self.hoolamike_config is None:
|
|
||||||
self.hoolamike_config = {}
|
|
||||||
self.hoolamike_config['games'] = {
|
|
||||||
self._format_game_name(game): {"root_directory": str(path)}
|
|
||||||
for game, path in detected_games.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Update TTW configuration
|
if success:
|
||||||
self._update_hoolamike_config_for_ttw(ttw_mpi_path, ttw_output_path)
|
print(f"\n{COLOR_SUCCESS}{message}{COLOR_RESET}")
|
||||||
if not self.save_hoolamike_config():
|
|
||||||
self.logger.error("Failed to save hoolamike.yaml configuration.")
|
|
||||||
print(f"{COLOR_ERROR}Error: Failed to save Hoolamike configuration.{COLOR_RESET}")
|
|
||||||
print("Attempting to continue anyway...")
|
|
||||||
|
|
||||||
# Construct command to execute
|
# Offer to create MO2 zip archive
|
||||||
cmd = [
|
print(f"\n{COLOR_INFO}Would you like to create a zipped mod archive for MO2?{COLOR_RESET}")
|
||||||
str(self.hoolamike_executable_path),
|
print(f"This will package the TTW files for easy installation into Mod Organizer 2.")
|
||||||
"tale-of-two-wastelands"
|
create_zip = input(f"{COLOR_PROMPT}Create zip archive? [Y/n]: {COLOR_RESET}").strip().lower()
|
||||||
]
|
|
||||||
self.logger.info(f"Executing Hoolamike command: {' '.join(cmd)}")
|
if not create_zip or create_zip.startswith('y'):
|
||||||
print(f"\n{COLOR_INFO}Executing Hoolamike for TTW Installation...{COLOR_RESET}")
|
zip_success = self._create_ttw_mod_archive_cli(ttw_mpi_path, ttw_output_path)
|
||||||
print(f"Command: {' '.join(cmd)}")
|
if not zip_success:
|
||||||
print(f"{COLOR_INFO}Streaming output below. Press Ctrl+C to cancel and return to Jackify menu.{COLOR_RESET}\n")
|
print(f"\n{COLOR_WARNING}Archive creation failed, but TTW installation completed successfully.{COLOR_RESET}")
|
||||||
try:
|
else:
|
||||||
ret = subprocess.call(cmd, cwd=str(self.hoolamike_app_install_path), env=get_clean_subprocess_env())
|
print(f"\n{COLOR_INFO}Skipping archive creation. You can manually use the TTW files from the output directory.{COLOR_RESET}")
|
||||||
if ret == 0:
|
|
||||||
self.logger.info("TTW installation completed successfully.")
|
input(f"\n{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
|
||||||
print(f"\n{COLOR_SUCCESS}TTW installation completed successfully!{COLOR_RESET}")
|
|
||||||
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
self.logger.error(f"TTW installation process returned non-zero exit code: {ret}")
|
print(f"\n{COLOR_ERROR}{message}{COLOR_RESET}")
|
||||||
print(f"\n{COLOR_ERROR}Error: TTW installation failed with exit code {ret}.{COLOR_RESET}")
|
|
||||||
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error executing Hoolamike TTW installation: {e}", exc_info=True)
|
|
||||||
print(f"\n{COLOR_ERROR}Error executing Hoolamike TTW installation: {e}{COLOR_RESET}")
|
|
||||||
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
|
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -818,27 +982,125 @@ class HoolamikeHandler:
|
|||||||
# Set destination variable
|
# Set destination variable
|
||||||
ttw_config["variables"]["DESTINATION"] = str(ttw_output_path)
|
ttw_config["variables"]["DESTINATION"] = str(ttw_output_path)
|
||||||
|
|
||||||
# Set USERPROFILE to a Jackify-managed directory for TTW
|
# Set USERPROFILE to Fallout New Vegas Wine prefix Documents folder
|
||||||
userprofile_path = str(self.hoolamike_app_install_path / "USERPROFILE")
|
userprofile_path = self._detect_fallout_nv_userprofile()
|
||||||
if "variables" not in self.hoolamike_config["extras"]["tale_of_two_wastelands"]:
|
if "variables" not in self.hoolamike_config["extras"]["tale_of_two_wastelands"]:
|
||||||
self.hoolamike_config["extras"]["tale_of_two_wastelands"]["variables"] = {}
|
self.hoolamike_config["extras"]["tale_of_two_wastelands"]["variables"] = {}
|
||||||
self.hoolamike_config["extras"]["tale_of_two_wastelands"]["variables"]["USERPROFILE"] = userprofile_path
|
self.hoolamike_config["extras"]["tale_of_two_wastelands"]["variables"]["USERPROFILE"] = userprofile_path
|
||||||
|
|
||||||
# Make sure game paths are set correctly
|
# Make sure game paths are set correctly using proper Hoolamike naming format
|
||||||
for game in ['Fallout 3', 'Fallout New Vegas']:
|
for game in ['Fallout 3', 'Fallout New Vegas']:
|
||||||
if game in self.game_install_paths:
|
if game in self.game_install_paths:
|
||||||
game_key = game.replace(' ', '').lower()
|
# Use _format_game_name to ensure correct naming (removes spaces)
|
||||||
|
formatted_game_name = self._format_game_name(game)
|
||||||
|
|
||||||
if "games" not in self.hoolamike_config:
|
if "games" not in self.hoolamike_config:
|
||||||
self.hoolamike_config["games"] = {}
|
self.hoolamike_config["games"] = {}
|
||||||
|
|
||||||
if game not in self.hoolamike_config["games"]:
|
if formatted_game_name not in self.hoolamike_config["games"]:
|
||||||
self.hoolamike_config["games"][game] = {}
|
self.hoolamike_config["games"][formatted_game_name] = {}
|
||||||
|
|
||||||
self.hoolamike_config["games"][game]["root_directory"] = str(self.game_install_paths[game])
|
self.hoolamike_config["games"][formatted_game_name]["root_directory"] = str(self.game_install_paths[game])
|
||||||
|
|
||||||
self.logger.info("Updated Hoolamike configuration with TTW settings.")
|
self.logger.info("Updated Hoolamike configuration with TTW settings.")
|
||||||
|
|
||||||
|
def _create_ttw_mod_archive_cli(self, ttw_mpi_path: Path, ttw_output_path: Path) -> bool:
|
||||||
|
"""Create a zipped mod archive of TTW output for MO2 installation (CLI version).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ttw_mpi_path: Path to the TTW .mpi file (used for version extraction)
|
||||||
|
ttw_output_path: Path to the TTW output directory to archive
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
import re
|
||||||
|
|
||||||
|
if not ttw_output_path.exists():
|
||||||
|
print(f"{COLOR_ERROR}Output directory does not exist: {ttw_output_path}{COLOR_RESET}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Extract version from .mpi filename (e.g., "TTW v3.4.mpi" -> "3.4")
|
||||||
|
version_suffix = ""
|
||||||
|
if ttw_mpi_path:
|
||||||
|
mpi_filename = ttw_mpi_path.stem # Get filename without extension
|
||||||
|
# Look for version pattern like "3.4", "v3.4", etc.
|
||||||
|
version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', mpi_filename, re.IGNORECASE)
|
||||||
|
if version_match:
|
||||||
|
version_suffix = f" {version_match.group(1)}"
|
||||||
|
|
||||||
|
# Create archive filename - [NoDelete] prefix is used by MO2 workflows
|
||||||
|
archive_name = f"[NoDelete] Tale of Two Wastelands{version_suffix}"
|
||||||
|
|
||||||
|
# Place archive in parent directory of output
|
||||||
|
archive_path = ttw_output_path.parent / archive_name
|
||||||
|
|
||||||
|
print(f"\n{COLOR_INFO}Creating mod archive: {archive_name}.zip{COLOR_RESET}")
|
||||||
|
print(f"{COLOR_INFO}This may take several minutes...{COLOR_RESET}")
|
||||||
|
|
||||||
|
# Create the zip archive
|
||||||
|
# shutil.make_archive returns the path without .zip extension
|
||||||
|
final_archive = shutil.make_archive(
|
||||||
|
str(archive_path), # base name (without extension)
|
||||||
|
'zip', # format
|
||||||
|
str(ttw_output_path) # directory to archive
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n{COLOR_SUCCESS}Archive created successfully: {Path(final_archive).name}{COLOR_RESET}")
|
||||||
|
print(f"{COLOR_INFO}Location: {final_archive}{COLOR_RESET}")
|
||||||
|
print(f"{COLOR_INFO}You can now install this archive as a mod in MO2.{COLOR_RESET}")
|
||||||
|
|
||||||
|
self.logger.info(f"Created TTW mod archive: {final_archive}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n{COLOR_ERROR}Failed to create mod archive: {e}{COLOR_RESET}")
|
||||||
|
self.logger.error(f"Failed to create TTW mod archive: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _detect_fallout_nv_userprofile(self) -> str:
|
||||||
|
"""
|
||||||
|
Detect the Fallout New Vegas Wine prefix Documents folder for USERPROFILE.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Path to the Fallout New Vegas Wine prefix Documents folder,
|
||||||
|
or fallback to Jackify-managed directory if not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Fallout New Vegas AppID
|
||||||
|
fnv_appid = "22380"
|
||||||
|
|
||||||
|
# Find the compatdata directory for Fallout New Vegas
|
||||||
|
compatdata_path = self.path_handler.find_compat_data(fnv_appid)
|
||||||
|
if not compatdata_path:
|
||||||
|
self.logger.warning(f"Could not find compatdata directory for Fallout New Vegas (AppID: {fnv_appid})")
|
||||||
|
# Fallback to Jackify-managed directory
|
||||||
|
fallback_path = str(self.hoolamike_app_install_path / "USERPROFILE")
|
||||||
|
self.logger.info(f"Using fallback USERPROFILE path: {fallback_path}")
|
||||||
|
return fallback_path
|
||||||
|
|
||||||
|
# Construct the Wine prefix Documents path
|
||||||
|
wine_documents_path = compatdata_path / "pfx" / "drive_c" / "users" / "steamuser" / "Documents" / "My Games" / "FalloutNV"
|
||||||
|
|
||||||
|
if wine_documents_path.exists():
|
||||||
|
self.logger.info(f"Found Fallout New Vegas Wine prefix Documents folder: {wine_documents_path}")
|
||||||
|
return str(wine_documents_path)
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"Fallout New Vegas Wine prefix Documents folder not found at: {wine_documents_path}")
|
||||||
|
# Fallback to Jackify-managed directory
|
||||||
|
fallback_path = str(self.hoolamike_app_install_path / "USERPROFILE")
|
||||||
|
self.logger.info(f"Using fallback USERPROFILE path: {fallback_path}")
|
||||||
|
return fallback_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error detecting Fallout New Vegas USERPROFILE: {e}", exc_info=True)
|
||||||
|
# Fallback to Jackify-managed directory
|
||||||
|
fallback_path = str(self.hoolamike_app_install_path / "USERPROFILE")
|
||||||
|
self.logger.info(f"Using fallback USERPROFILE path: {fallback_path}")
|
||||||
|
return fallback_path
|
||||||
|
|
||||||
def reset_config(self):
|
def reset_config(self):
|
||||||
"""Resets the hoolamike.yaml to default settings, backing up any existing file."""
|
"""Resets the hoolamike.yaml to default settings, backing up any existing file."""
|
||||||
if self.hoolamike_config_path.is_file():
|
if self.hoolamike_config_path.is_file():
|
||||||
@@ -973,6 +1235,165 @@ class HoolamikeHandler:
|
|||||||
self.logger.error(f"Error launching or waiting for editor: {e}")
|
self.logger.error(f"Error launching or waiting for editor: {e}")
|
||||||
print(f"{COLOR_ERROR}An error occurred while launching the editor: {e}{COLOR_RESET}")
|
print(f"{COLOR_ERROR}An error occurred while launching the editor: {e}{COLOR_RESET}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str) -> bool:
|
||||||
|
"""Integrate TTW output into a modlist's MO2 structure
|
||||||
|
|
||||||
|
This method:
|
||||||
|
1. Copies TTW output to the modlist's mods folder
|
||||||
|
2. Updates modlist.txt for all profiles
|
||||||
|
3. Updates plugins.txt with TTW ESMs in correct order
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ttw_output_path: Path to TTW output directory
|
||||||
|
modlist_install_dir: Path to modlist installation directory
|
||||||
|
ttw_version: TTW version string (e.g., "3.4")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if integration successful, False otherwise
|
||||||
|
"""
|
||||||
|
logging_handler = LoggingHandler()
|
||||||
|
logging_handler.rotate_log_for_logger('ttw-install', 'TTW_Install_workflow.log')
|
||||||
|
logger = logging_handler.setup_logger('ttw-install', 'TTW_Install_workflow.log')
|
||||||
|
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Validate paths
|
||||||
|
if not ttw_output_path.exists():
|
||||||
|
logger.error(f"TTW output path does not exist: {ttw_output_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
mods_dir = modlist_install_dir / "mods"
|
||||||
|
profiles_dir = modlist_install_dir / "profiles"
|
||||||
|
|
||||||
|
if not mods_dir.exists() or not profiles_dir.exists():
|
||||||
|
logger.error(f"Invalid modlist directory structure: {modlist_install_dir}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create mod folder name with version
|
||||||
|
mod_folder_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}" if ttw_version else "[NoDelete] Tale of Two Wastelands"
|
||||||
|
target_mod_dir = mods_dir / mod_folder_name
|
||||||
|
|
||||||
|
# Copy TTW output to mods directory
|
||||||
|
logger.info(f"Copying TTW output to {target_mod_dir}")
|
||||||
|
if target_mod_dir.exists():
|
||||||
|
logger.info(f"Removing existing TTW mod at {target_mod_dir}")
|
||||||
|
shutil.rmtree(target_mod_dir)
|
||||||
|
|
||||||
|
shutil.copytree(ttw_output_path, target_mod_dir)
|
||||||
|
logger.info("TTW output copied successfully")
|
||||||
|
|
||||||
|
# TTW ESMs in correct load order
|
||||||
|
ttw_esms = [
|
||||||
|
"Fallout3.esm",
|
||||||
|
"Anchorage.esm",
|
||||||
|
"ThePitt.esm",
|
||||||
|
"BrokenSteel.esm",
|
||||||
|
"PointLookout.esm",
|
||||||
|
"Zeta.esm",
|
||||||
|
"TaleOfTwoWastelands.esm",
|
||||||
|
"YUPTTW.esm"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Process each profile
|
||||||
|
for profile_dir in profiles_dir.iterdir():
|
||||||
|
if not profile_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
profile_name = profile_dir.name
|
||||||
|
logger.info(f"Processing profile: {profile_name}")
|
||||||
|
|
||||||
|
# Update modlist.txt
|
||||||
|
modlist_file = profile_dir / "modlist.txt"
|
||||||
|
if modlist_file.exists():
|
||||||
|
# Read existing modlist
|
||||||
|
with open(modlist_file, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
# Find the TTW placeholder separator and insert BEFORE it
|
||||||
|
separator_found = False
|
||||||
|
ttw_mod_line = f"+{mod_folder_name}\n"
|
||||||
|
new_lines = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
# Skip existing TTW mod entries (but keep separators and other TTW-related mods)
|
||||||
|
# Match patterns: "+[NoDelete] Tale of Two Wastelands", "+[NoDelete] TTW", etc.
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith('+') and '[nodelete]' in stripped.lower():
|
||||||
|
# Check if it's the main TTW mod (not other TTW-related mods like "TTW Quick Start")
|
||||||
|
if ('tale of two wastelands' in stripped.lower() and 'quick start' not in stripped.lower() and
|
||||||
|
'loading wheel' not in stripped.lower()) or stripped.lower().startswith('+[nodelete] ttw '):
|
||||||
|
logger.info(f"Removing existing TTW mod entry: {stripped}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Insert TTW mod BEFORE the placeholder separator (MO2 order is bottom-up)
|
||||||
|
# Check BEFORE appending so TTW mod appears before separator in file
|
||||||
|
if "put tale of two wastelands mod here" in line.lower() and "_separator" in line.lower():
|
||||||
|
new_lines.append(ttw_mod_line)
|
||||||
|
separator_found = True
|
||||||
|
logger.info(f"Inserted TTW mod before separator: {line.strip()}")
|
||||||
|
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
# If no separator found, append at the end
|
||||||
|
if not separator_found:
|
||||||
|
new_lines.append(ttw_mod_line)
|
||||||
|
logger.warning(f"No TTW separator found in {profile_name}, appended to end")
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
with open(modlist_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.writelines(new_lines)
|
||||||
|
|
||||||
|
logger.info(f"Updated modlist.txt for {profile_name}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"modlist.txt not found for profile {profile_name}")
|
||||||
|
|
||||||
|
# Update plugins.txt
|
||||||
|
plugins_file = profile_dir / "plugins.txt"
|
||||||
|
if plugins_file.exists():
|
||||||
|
# Read existing plugins
|
||||||
|
with open(plugins_file, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
# Remove any existing TTW ESMs
|
||||||
|
ttw_esm_set = set(esm.lower() for esm in ttw_esms)
|
||||||
|
lines = [line for line in lines if line.strip().lower() not in ttw_esm_set]
|
||||||
|
|
||||||
|
# Find CaravanPack.esm and insert TTW ESMs after it
|
||||||
|
insert_index = None
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.strip().lower() == "caravanpack.esm":
|
||||||
|
insert_index = i + 1
|
||||||
|
break
|
||||||
|
|
||||||
|
if insert_index is not None:
|
||||||
|
# Insert TTW ESMs in correct order
|
||||||
|
for esm in reversed(ttw_esms):
|
||||||
|
lines.insert(insert_index, f"{esm}\n")
|
||||||
|
else:
|
||||||
|
logger.warning(f"CaravanPack.esm not found in {profile_name}, appending TTW ESMs to end")
|
||||||
|
for esm in ttw_esms:
|
||||||
|
lines.append(f"{esm}\n")
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
with open(plugins_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
|
||||||
|
logger.info(f"Updated plugins.txt for {profile_name}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"plugins.txt not found for profile {profile_name}")
|
||||||
|
|
||||||
|
logger.info("TTW integration completed successfully")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to integrate TTW into modlist: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
# Example usage (for testing, remove later)
|
# Example usage (for testing, remove later)
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
|||||||
@@ -689,25 +689,6 @@ class ModlistHandler:
|
|||||||
return False
|
return False
|
||||||
self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.")
|
self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.")
|
||||||
|
|
||||||
# Step 3.5: Apply universal dotnet4.x compatibility registry fixes
|
|
||||||
if status_callback:
|
|
||||||
status_callback(f"{self._get_progress_timestamp()} Applying universal dotnet4.x compatibility fixes")
|
|
||||||
self.logger.info("Step 3.5: Applying universal dotnet4.x compatibility registry fixes...")
|
|
||||||
registry_success = False
|
|
||||||
try:
|
|
||||||
registry_success = self._apply_universal_dotnet_fixes()
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"CRITICAL: Registry fixes failed - modlist may have .NET compatibility issues: {e}")
|
|
||||||
registry_success = False
|
|
||||||
|
|
||||||
if not registry_success:
|
|
||||||
self.logger.error("=" * 80)
|
|
||||||
self.logger.error("WARNING: Universal dotnet4.x registry fixes FAILED!")
|
|
||||||
self.logger.error("This modlist may experience .NET Framework compatibility issues.")
|
|
||||||
self.logger.error("Consider manually setting mscoree=native in winecfg if problems occur.")
|
|
||||||
self.logger.error("=" * 80)
|
|
||||||
# Continue but user should be aware of potential issues
|
|
||||||
|
|
||||||
# Step 4: Install Wine Components
|
# Step 4: Install Wine Components
|
||||||
if status_callback:
|
if status_callback:
|
||||||
status_callback(f"{self._get_progress_timestamp()} Installing Wine components (this may take a while)")
|
status_callback(f"{self._get_progress_timestamp()} Installing Wine components (this may take a while)")
|
||||||
@@ -751,6 +732,26 @@ class ModlistHandler:
|
|||||||
return False
|
return False
|
||||||
self.logger.info("Step 4: Installing Wine components... Done")
|
self.logger.info("Step 4: Installing Wine components... Done")
|
||||||
|
|
||||||
|
# Step 4.5: Apply universal dotnet4.x compatibility registry fixes AFTER wine components
|
||||||
|
# This ensures the fixes are not overwritten by component installation processes
|
||||||
|
if status_callback:
|
||||||
|
status_callback(f"{self._get_progress_timestamp()} Applying universal dotnet4.x compatibility fixes")
|
||||||
|
self.logger.info("Step 4.5: Applying universal dotnet4.x compatibility registry fixes...")
|
||||||
|
registry_success = False
|
||||||
|
try:
|
||||||
|
registry_success = self._apply_universal_dotnet_fixes()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"CRITICAL: Registry fixes failed - modlist may have .NET compatibility issues: {e}")
|
||||||
|
registry_success = False
|
||||||
|
|
||||||
|
if not registry_success:
|
||||||
|
self.logger.error("=" * 80)
|
||||||
|
self.logger.error("WARNING: Universal dotnet4.x registry fixes FAILED!")
|
||||||
|
self.logger.error("This modlist may experience .NET Framework compatibility issues.")
|
||||||
|
self.logger.error("Consider manually setting mscoree=native in winecfg if problems occur.")
|
||||||
|
self.logger.error("=" * 80)
|
||||||
|
# Continue but user should be aware of potential issues
|
||||||
|
|
||||||
# Step 5: Ensure permissions of Modlist directory
|
# Step 5: Ensure permissions of Modlist directory
|
||||||
if status_callback:
|
if status_callback:
|
||||||
status_callback(f"{self._get_progress_timestamp()} Setting ownership and permissions for modlist directory")
|
status_callback(f"{self._get_progress_timestamp()} Setting ownership and permissions for modlist directory")
|
||||||
@@ -1528,14 +1529,18 @@ class ModlistHandler:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _apply_universal_dotnet_fixes(self):
|
def _apply_universal_dotnet_fixes(self):
|
||||||
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists"""
|
"""
|
||||||
|
Apply universal dotnet4.x compatibility registry fixes to ALL modlists.
|
||||||
|
Now called AFTER wine component installation to prevent overwrites.
|
||||||
|
Includes wineserver shutdown/flush to ensure persistence.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
|
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
|
||||||
if not os.path.exists(prefix_path):
|
if not os.path.exists(prefix_path):
|
||||||
self.logger.warning(f"Prefix path not found: {prefix_path}")
|
self.logger.warning(f"Prefix path not found: {prefix_path}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.logger.info("Applying universal dotnet4.x compatibility registry fixes...")
|
self.logger.info("Applying universal dotnet4.x compatibility registry fixes (post-component installation)...")
|
||||||
|
|
||||||
# Find the appropriate Wine binary to use for registry operations
|
# Find the appropriate Wine binary to use for registry operations
|
||||||
wine_binary = self._find_wine_binary_for_registry()
|
wine_binary = self._find_wine_binary_for_registry()
|
||||||
@@ -1543,11 +1548,27 @@ class ModlistHandler:
|
|||||||
self.logger.error("Could not find Wine binary for registry operations")
|
self.logger.error("Could not find Wine binary for registry operations")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Find wineserver binary for flushing registry changes
|
||||||
|
wine_dir = os.path.dirname(wine_binary)
|
||||||
|
wineserver_binary = os.path.join(wine_dir, 'wineserver')
|
||||||
|
if not os.path.exists(wineserver_binary):
|
||||||
|
self.logger.warning(f"wineserver not found at {wineserver_binary}, registry flush may not work")
|
||||||
|
wineserver_binary = None
|
||||||
|
|
||||||
# Set environment for Wine registry operations
|
# Set environment for Wine registry operations
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env['WINEPREFIX'] = prefix_path
|
env['WINEPREFIX'] = prefix_path
|
||||||
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
|
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
|
||||||
|
|
||||||
|
# Shutdown any running wineserver processes to ensure clean slate
|
||||||
|
if wineserver_binary:
|
||||||
|
self.logger.debug("Shutting down wineserver before applying registry fixes...")
|
||||||
|
try:
|
||||||
|
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
|
||||||
|
self.logger.debug("Wineserver shutdown complete")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}")
|
||||||
|
|
||||||
# Registry fix 1: Set mscoree=native DLL override
|
# Registry fix 1: Set mscoree=native DLL override
|
||||||
# This tells Wine to use native .NET runtime instead of Wine's implementation
|
# This tells Wine to use native .NET runtime instead of Wine's implementation
|
||||||
self.logger.debug("Setting mscoree=native DLL override...")
|
self.logger.debug("Setting mscoree=native DLL override...")
|
||||||
@@ -1557,7 +1578,7 @@ class ModlistHandler:
|
|||||||
'/v', 'mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
|
'/v', 'mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
|
||||||
]
|
]
|
||||||
|
|
||||||
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True)
|
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
|
||||||
if result1.returncode == 0:
|
if result1.returncode == 0:
|
||||||
self.logger.info("Successfully applied mscoree=native DLL override")
|
self.logger.info("Successfully applied mscoree=native DLL override")
|
||||||
else:
|
else:
|
||||||
@@ -1572,18 +1593,57 @@ class ModlistHandler:
|
|||||||
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
|
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
|
||||||
]
|
]
|
||||||
|
|
||||||
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True)
|
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
|
||||||
if result2.returncode == 0:
|
if result2.returncode == 0:
|
||||||
self.logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
|
self.logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Failed to set OnlyUseLatestCLR: {result2.stderr}")
|
self.logger.warning(f"Failed to set OnlyUseLatestCLR: {result2.stderr}")
|
||||||
|
|
||||||
# Both fixes applied - this should eliminate dotnet4.x installation requirements
|
# Force wineserver to flush registry changes to disk
|
||||||
if result1.returncode == 0 and result2.returncode == 0:
|
if wineserver_binary:
|
||||||
self.logger.info("Universal dotnet4.x compatibility fixes applied successfully")
|
self.logger.debug("Flushing registry changes to disk via wineserver shutdown...")
|
||||||
|
try:
|
||||||
|
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
|
||||||
|
self.logger.debug("Registry changes flushed to disk")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Registry flush failed (non-critical): {e}")
|
||||||
|
|
||||||
|
# VERIFICATION: Confirm the registry entries persisted
|
||||||
|
self.logger.info("Verifying registry entries were applied and persisted...")
|
||||||
|
verification_passed = True
|
||||||
|
|
||||||
|
# Verify mscoree=native
|
||||||
|
verify_cmd1 = [
|
||||||
|
wine_binary, 'reg', 'query',
|
||||||
|
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
|
||||||
|
'/v', 'mscoree'
|
||||||
|
]
|
||||||
|
verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
|
||||||
|
if verify_result1.returncode == 0 and 'native' in verify_result1.stdout:
|
||||||
|
self.logger.info("VERIFIED: mscoree=native is set correctly")
|
||||||
|
else:
|
||||||
|
self.logger.error(f"VERIFICATION FAILED: mscoree=native not found in registry. Query output: {verify_result1.stdout}")
|
||||||
|
verification_passed = False
|
||||||
|
|
||||||
|
# Verify OnlyUseLatestCLR=1
|
||||||
|
verify_cmd2 = [
|
||||||
|
wine_binary, 'reg', 'query',
|
||||||
|
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
|
||||||
|
'/v', 'OnlyUseLatestCLR'
|
||||||
|
]
|
||||||
|
verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
|
||||||
|
if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout):
|
||||||
|
self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly")
|
||||||
|
else:
|
||||||
|
self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}")
|
||||||
|
verification_passed = False
|
||||||
|
|
||||||
|
# Both fixes applied and verified
|
||||||
|
if result1.returncode == 0 and result2.returncode == 0 and verification_passed:
|
||||||
|
self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
self.logger.warning("Some dotnet4.x registry fixes failed, but continuing...")
|
self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -945,6 +945,9 @@ class ModlistInstallCLI:
|
|||||||
|
|
||||||
if configuration_success:
|
if configuration_success:
|
||||||
self.logger.info("Post-installation configuration completed successfully")
|
self.logger.info("Post-installation configuration completed successfully")
|
||||||
|
|
||||||
|
# Check for TTW integration eligibility
|
||||||
|
self._check_and_prompt_ttw_integration(install_dir_str, detected_game, modlist_name)
|
||||||
else:
|
else:
|
||||||
self.logger.warning("Post-installation configuration had issues")
|
self.logger.warning("Post-installation configuration had issues")
|
||||||
else:
|
else:
|
||||||
@@ -1136,3 +1139,157 @@ class ModlistInstallCLI:
|
|||||||
return f"{line}\n Nexus URL: {mod_url}"
|
return f"{line}\n Nexus URL: {mod_url}"
|
||||||
|
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
def _check_and_prompt_ttw_integration(self, install_dir: str, game_type: str, modlist_name: str):
|
||||||
|
"""Check if modlist is eligible for TTW integration and prompt user"""
|
||||||
|
try:
|
||||||
|
# Check eligibility: FNV game, TTW-compatible modlist, no existing TTW
|
||||||
|
if not self._is_ttw_eligible(install_dir, game_type, modlist_name):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Prompt user for TTW installation
|
||||||
|
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"TTW combines Fallout 3 and New Vegas into a single game.")
|
||||||
|
print(f"\nWould you like to install TTW now?")
|
||||||
|
|
||||||
|
user_input = input(f"{COLOR_PROMPT}Install TTW? (yes/no): {COLOR_RESET}").strip().lower()
|
||||||
|
|
||||||
|
if user_input in ['yes', 'y']:
|
||||||
|
self._launch_ttw_installation(modlist_name, install_dir)
|
||||||
|
else:
|
||||||
|
print(f"{COLOR_INFO}Skipping TTW installation. You can install it later from the main menu.{COLOR_RESET}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error during TTW eligibility check: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _is_ttw_eligible(self, install_dir: str, game_type: str, modlist_name: str) -> bool:
|
||||||
|
"""Check if modlist is eligible for TTW integration"""
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Check 1: Must be Fallout New Vegas
|
||||||
|
if not game_type or game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check 2: Must be on TTW compatibility whitelist
|
||||||
|
from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible
|
||||||
|
if not is_ttw_compatible(modlist_name):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check 3: TTW must not already be installed
|
||||||
|
if self._detect_existing_ttw(install_dir):
|
||||||
|
self.logger.info(f"TTW already installed in {install_dir}, skipping prompt")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error checking TTW eligibility: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _detect_existing_ttw(self, install_dir: str) -> bool:
|
||||||
|
"""Detect if TTW is already installed in the modlist"""
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
install_path = Path(install_dir)
|
||||||
|
|
||||||
|
# Search for TTW indicators in common locations
|
||||||
|
search_paths = [
|
||||||
|
install_path,
|
||||||
|
install_path / "mods",
|
||||||
|
install_path / "Stock Game",
|
||||||
|
install_path / "Game Root"
|
||||||
|
]
|
||||||
|
|
||||||
|
for search_path in search_paths:
|
||||||
|
if not search_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Look for folders containing "tale" and "two" and "wastelands"
|
||||||
|
for folder in search_path.iterdir():
|
||||||
|
if not folder.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
folder_name_lower = folder.name.lower()
|
||||||
|
if all(keyword in folder_name_lower for keyword in ['tale', 'two', 'wastelands']):
|
||||||
|
# Verify it has the TTW ESM file
|
||||||
|
for file in folder.rglob('*.esm'):
|
||||||
|
if 'taleoftwowastelands' in file.name.lower():
|
||||||
|
self.logger.info(f"Found existing TTW installation: {file}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error detecting existing TTW: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _launch_ttw_installation(self, modlist_name: str, install_dir: str):
|
||||||
|
"""Launch TTW installation workflow"""
|
||||||
|
try:
|
||||||
|
print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}")
|
||||||
|
|
||||||
|
# Import TTW installation handler
|
||||||
|
from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler
|
||||||
|
from jackify.backend.models.configuration import SystemInfo
|
||||||
|
|
||||||
|
system_info = SystemInfo()
|
||||||
|
hoolamike_handler = HoolamikeHandler(system_info)
|
||||||
|
|
||||||
|
# Check if Hoolamike is installed
|
||||||
|
is_installed, installed_version = hoolamike_handler.check_installation_status()
|
||||||
|
|
||||||
|
if not is_installed:
|
||||||
|
print(f"{COLOR_INFO}Hoolamike (TTW installer) is not installed.{COLOR_RESET}")
|
||||||
|
user_input = input(f"{COLOR_PROMPT}Install Hoolamike? (yes/no): {COLOR_RESET}").strip().lower()
|
||||||
|
|
||||||
|
if user_input not in ['yes', 'y']:
|
||||||
|
print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Install Hoolamike
|
||||||
|
print(f"{COLOR_INFO}Installing Hoolamike...{COLOR_RESET}")
|
||||||
|
success, message = hoolamike_handler.install_hoolamike()
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print(f"{COLOR_ERROR}Failed to install Hoolamike: {message}{COLOR_RESET}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{COLOR_INFO}Hoolamike installed successfully.{COLOR_RESET}")
|
||||||
|
|
||||||
|
# Get Hoolamike MPI path
|
||||||
|
mpi_path = hoolamike_handler.get_mpi_path()
|
||||||
|
if not mpi_path or not os.path.exists(mpi_path):
|
||||||
|
print(f"{COLOR_ERROR}Hoolamike MPI file not found at: {mpi_path}{COLOR_RESET}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Prompt for TTW installation directory
|
||||||
|
print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}")
|
||||||
|
print(f"Default: {os.path.join(install_dir, 'TTW')}")
|
||||||
|
ttw_install_dir = input(f"{COLOR_PROMPT}TTW install directory (Enter for default): {COLOR_RESET}").strip()
|
||||||
|
|
||||||
|
if not ttw_install_dir:
|
||||||
|
ttw_install_dir = os.path.join(install_dir, "TTW")
|
||||||
|
|
||||||
|
# Run Hoolamike installation
|
||||||
|
print(f"\n{COLOR_INFO}Installing TTW using Hoolamike...{COLOR_RESET}")
|
||||||
|
print(f"{COLOR_INFO}This may take a while (15-30 minutes depending on your system).{COLOR_RESET}")
|
||||||
|
|
||||||
|
success = hoolamike_handler.run_hoolamike_install(mpi_path, ttw_install_dir)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"\n{COLOR_INFO}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
|
||||||
|
print(f"{COLOR_INFO}TTW Installation Complete!{COLOR_RESET}")
|
||||||
|
print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
|
||||||
|
print(f"\nTTW has been installed to: {ttw_install_dir}")
|
||||||
|
print(f"The modlist '{modlist_name}' is now ready to use with TTW.")
|
||||||
|
else:
|
||||||
|
print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{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}")
|
||||||
@@ -676,10 +676,10 @@ class PathHandler:
|
|||||||
|
|
||||||
# For each library path, look for each target game
|
# For each library path, look for each target game
|
||||||
for library_path in library_paths:
|
for library_path in library_paths:
|
||||||
# Check if the common directory exists
|
# Check if the common directory exists (games are in steamapps/common)
|
||||||
common_dir = library_path / "common"
|
common_dir = library_path / "steamapps" / "common"
|
||||||
if not common_dir.is_dir():
|
if not common_dir.is_dir():
|
||||||
logger.debug(f"No 'common' directory in library: {library_path}")
|
logger.debug(f"No 'steamapps/common' directory in library: {library_path}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get subdirectories in common dir
|
# Get subdirectories in common dir
|
||||||
@@ -694,8 +694,8 @@ class PathHandler:
|
|||||||
if game_name in results:
|
if game_name in results:
|
||||||
continue # Already found this game
|
continue # Already found this game
|
||||||
|
|
||||||
# Try to find by appmanifest
|
# Try to find by appmanifest (manifests are in steamapps subdirectory)
|
||||||
appmanifest_path = library_path / f"appmanifest_{app_id}.acf"
|
appmanifest_path = library_path / "steamapps" / f"appmanifest_{app_id}.acf"
|
||||||
if appmanifest_path.is_file():
|
if appmanifest_path.is_file():
|
||||||
# Find the installdir value
|
# Find the installdir value
|
||||||
try:
|
try:
|
||||||
@@ -799,7 +799,8 @@ class PathHandler:
|
|||||||
match = re.match(sdcard_pattern, existing_game_path)
|
match = re.match(sdcard_pattern, existing_game_path)
|
||||||
if match:
|
if match:
|
||||||
stripped_path = match.group(1) # Just the /Games/... part
|
stripped_path = match.group(1) # Just the /Games/... part
|
||||||
new_gamepath_value = f"D:\\\\{stripped_path.replace('/', '\\\\')}"
|
windows_path = stripped_path.replace('/', '\\\\')
|
||||||
|
new_gamepath_value = f"D:\\\\{windows_path}"
|
||||||
new_gamepath_line = f"gamePath = @ByteArray({new_gamepath_value})\n"
|
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()}")
|
logger.info(f"Updating gamePath for SD card: {lines[gamepath_line_index].strip()} -> {new_gamepath_line.strip()}")
|
||||||
|
|||||||
@@ -291,6 +291,9 @@ class WinetricksHandler:
|
|||||||
|
|
||||||
# For non-dotnet40 installations, install all components together (faster)
|
# For non-dotnet40 installations, install all components together (faster)
|
||||||
max_attempts = 3
|
max_attempts = 3
|
||||||
|
winetricks_failed = False
|
||||||
|
last_error_details = None
|
||||||
|
|
||||||
for attempt in range(1, max_attempts + 1):
|
for attempt in range(1, max_attempts + 1):
|
||||||
if attempt > 1:
|
if attempt > 1:
|
||||||
self.logger.warning(f"Retrying component installation (attempt {attempt}/{max_attempts})...")
|
self.logger.warning(f"Retrying component installation (attempt {attempt}/{max_attempts})...")
|
||||||
@@ -301,9 +304,40 @@ class WinetricksHandler:
|
|||||||
cmd = [self.winetricks_path, '--unattended'] + components_to_install
|
cmd = [self.winetricks_path, '--unattended'] + components_to_install
|
||||||
|
|
||||||
self.logger.debug(f"Running: {' '.join(cmd)}")
|
self.logger.debug(f"Running: {' '.join(cmd)}")
|
||||||
self.logger.debug(f"Environment WINE={env.get('WINE', 'NOT SET')}")
|
|
||||||
self.logger.debug(f"Environment DISPLAY={env.get('DISPLAY', 'NOT SET')}")
|
# Enhanced diagnostics for bundled winetricks
|
||||||
self.logger.debug(f"Environment WINEPREFIX={env.get('WINEPREFIX', 'NOT SET')}")
|
self.logger.debug("=== Winetricks Environment Diagnostics ===")
|
||||||
|
self.logger.debug(f"Bundled winetricks path: {self.winetricks_path}")
|
||||||
|
self.logger.debug(f"Winetricks exists: {os.path.exists(self.winetricks_path)}")
|
||||||
|
self.logger.debug(f"Winetricks executable: {os.access(self.winetricks_path, os.X_OK)}")
|
||||||
|
if os.path.exists(self.winetricks_path):
|
||||||
|
try:
|
||||||
|
winetricks_stat = os.stat(self.winetricks_path)
|
||||||
|
self.logger.debug(f"Winetricks permissions: {oct(winetricks_stat.st_mode)}")
|
||||||
|
self.logger.debug(f"Winetricks size: {winetricks_stat.st_size} bytes")
|
||||||
|
except Exception as stat_err:
|
||||||
|
self.logger.debug(f"Could not stat winetricks: {stat_err}")
|
||||||
|
|
||||||
|
self.logger.debug(f"WINE binary: {env.get('WINE', 'NOT SET')}")
|
||||||
|
wine_binary = env.get('WINE', '')
|
||||||
|
if wine_binary and os.path.exists(wine_binary):
|
||||||
|
self.logger.debug(f"WINE binary exists: True")
|
||||||
|
else:
|
||||||
|
self.logger.debug(f"WINE binary exists: False")
|
||||||
|
|
||||||
|
self.logger.debug(f"WINEPREFIX: {env.get('WINEPREFIX', 'NOT SET')}")
|
||||||
|
wineprefix = env.get('WINEPREFIX', '')
|
||||||
|
if wineprefix and os.path.exists(wineprefix):
|
||||||
|
self.logger.debug(f"WINEPREFIX exists: True")
|
||||||
|
self.logger.debug(f"WINEPREFIX/pfx exists: {os.path.exists(os.path.join(wineprefix, 'pfx'))}")
|
||||||
|
else:
|
||||||
|
self.logger.debug(f"WINEPREFIX exists: False")
|
||||||
|
|
||||||
|
self.logger.debug(f"DISPLAY: {env.get('DISPLAY', 'NOT SET')}")
|
||||||
|
self.logger.debug(f"WINETRICKS_CACHE: {env.get('WINETRICKS_CACHE', 'NOT SET')}")
|
||||||
|
self.logger.debug(f"Components to install: {components_to_install}")
|
||||||
|
self.logger.debug("==========================================")
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
env=env,
|
env=env,
|
||||||
@@ -337,14 +371,92 @@ class WinetricksHandler:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Could not read winetricks.log: {e}")
|
self.logger.warning(f"Could not read winetricks.log: {e}")
|
||||||
|
|
||||||
|
# Store detailed error information for fallback diagnostics
|
||||||
|
last_error_details = {
|
||||||
|
'returncode': result.returncode,
|
||||||
|
'stdout': result.stdout.strip(),
|
||||||
|
'stderr': result.stderr.strip(),
|
||||||
|
'attempt': attempt
|
||||||
|
}
|
||||||
|
|
||||||
self.logger.error(f"Winetricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode}")
|
self.logger.error(f"Winetricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode}")
|
||||||
self.logger.error(f"Stdout: {result.stdout.strip()}")
|
self.logger.error(f"Stdout: {result.stdout.strip()}")
|
||||||
self.logger.error(f"Stderr: {result.stderr.strip()}")
|
self.logger.error(f"Stderr: {result.stderr.strip()}")
|
||||||
|
|
||||||
|
# Enhanced error diagnostics with actionable information
|
||||||
|
stderr_lower = result.stderr.lower()
|
||||||
|
stdout_lower = result.stdout.lower()
|
||||||
|
|
||||||
|
if "command not found" in stderr_lower or "no such file" in stderr_lower:
|
||||||
|
self.logger.error("DIAGNOSTIC: Winetricks or dependency binary not found")
|
||||||
|
self.logger.error(" - Bundled winetricks may be missing dependencies")
|
||||||
|
self.logger.error(" - Will attempt protontricks fallback if all attempts fail")
|
||||||
|
elif "permission denied" in stderr_lower:
|
||||||
|
self.logger.error("DIAGNOSTIC: Permission issue detected")
|
||||||
|
self.logger.error(f" - Check permissions on: {self.winetricks_path}")
|
||||||
|
self.logger.error(f" - Check permissions on WINEPREFIX: {env.get('WINEPREFIX', 'N/A')}")
|
||||||
|
elif "timeout" in stderr_lower:
|
||||||
|
self.logger.error("DIAGNOSTIC: Timeout issue detected during component download/install")
|
||||||
|
elif "sha256sum mismatch" in stderr_lower or "sha256sum" in stdout_lower:
|
||||||
|
self.logger.error("DIAGNOSTIC: Checksum verification failed")
|
||||||
|
self.logger.error(" - Component download may be corrupted")
|
||||||
|
self.logger.error(" - Network issue or upstream file change")
|
||||||
|
elif "curl" in stderr_lower or "wget" in stderr_lower:
|
||||||
|
self.logger.error("DIAGNOSTIC: Download tool (curl/wget) issue")
|
||||||
|
self.logger.error(" - Network connectivity problem or missing download tool")
|
||||||
|
elif "cabextract" in stderr_lower:
|
||||||
|
self.logger.error("DIAGNOSTIC: cabextract missing or failed")
|
||||||
|
self.logger.error(" - Required for extracting Windows cabinet files")
|
||||||
|
elif "unzip" in stderr_lower:
|
||||||
|
self.logger.error("DIAGNOSTIC: unzip missing or failed")
|
||||||
|
self.logger.error(" - Required for extracting zip archives")
|
||||||
|
else:
|
||||||
|
self.logger.error("DIAGNOSTIC: Unknown winetricks failure")
|
||||||
|
self.logger.error(" - Check full logs for details")
|
||||||
|
self.logger.error(" - Will attempt protontricks fallback if all attempts fail")
|
||||||
|
|
||||||
|
winetricks_failed = True
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
self.logger.error(f"Winetricks timed out (Attempt {attempt}/{max_attempts}): {e}")
|
||||||
|
last_error_details = {'error': 'timeout', 'attempt': attempt}
|
||||||
|
winetricks_failed = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error during winetricks run (Attempt {attempt}/{max_attempts}): {e}", exc_info=True)
|
self.logger.error(f"Error during winetricks run (Attempt {attempt}/{max_attempts}): {e}", exc_info=True)
|
||||||
|
last_error_details = {'error': str(e), 'attempt': attempt}
|
||||||
|
winetricks_failed = True
|
||||||
|
|
||||||
|
# All winetricks attempts failed - try automatic fallback to protontricks
|
||||||
|
if winetricks_failed:
|
||||||
|
self.logger.error(f"Winetricks failed after {max_attempts} attempts.")
|
||||||
|
|
||||||
|
# Check if protontricks is available for fallback
|
||||||
|
try:
|
||||||
|
protontricks_check = subprocess.run(['which', 'protontricks'],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
if protontricks_check.returncode == 0:
|
||||||
|
self.logger.warning("=" * 80)
|
||||||
|
self.logger.warning("AUTOMATIC FALLBACK: Winetricks failed, attempting protontricks fallback...")
|
||||||
|
self.logger.warning(f"Last winetricks error: {last_error_details}")
|
||||||
|
self.logger.warning("=" * 80)
|
||||||
|
|
||||||
|
# Attempt fallback to protontricks
|
||||||
|
fallback_success = self._install_components_protontricks_only(components_to_install, wineprefix, game_var)
|
||||||
|
|
||||||
|
if fallback_success:
|
||||||
|
self.logger.info("SUCCESS: Protontricks fallback succeeded where winetricks failed")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.logger.error("FAILURE: Both winetricks and protontricks fallback failed")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.logger.error("Protontricks not available for fallback")
|
||||||
|
self.logger.error(f"Final winetricks error details: {last_error_details}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Could not check for protontricks fallback: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
self.logger.error(f"Failed to install Wine components after {max_attempts} attempts.")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _reorder_components_for_installation(self, components: list) -> list:
|
def _reorder_components_for_installation(self, components: list) -> list:
|
||||||
|
|||||||
@@ -3020,7 +3020,7 @@ echo Prefix creation complete.
|
|||||||
'/v', 'mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
|
'/v', 'mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
|
||||||
]
|
]
|
||||||
|
|
||||||
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True)
|
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace')
|
||||||
if result1.returncode == 0:
|
if result1.returncode == 0:
|
||||||
logger.info("Successfully applied mscoree=native DLL override")
|
logger.info("Successfully applied mscoree=native DLL override")
|
||||||
else:
|
else:
|
||||||
@@ -3035,7 +3035,7 @@ echo Prefix creation complete.
|
|||||||
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
|
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
|
||||||
]
|
]
|
||||||
|
|
||||||
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True)
|
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace')
|
||||||
if result2.returncode == 0:
|
if result2.returncode == 0:
|
||||||
logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
|
logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -451,10 +451,8 @@ class JackifyCLI:
|
|||||||
elif choice == "wabbajack":
|
elif choice == "wabbajack":
|
||||||
self.menus['wabbajack'].show_wabbajack_tasks_menu(self)
|
self.menus['wabbajack'].show_wabbajack_tasks_menu(self)
|
||||||
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
|
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
|
||||||
# elif choice == "hoolamike":
|
elif choice == "additional":
|
||||||
# self.menus['hoolamike'].show_hoolamike_menu(self)
|
self.menus['additional'].show_additional_tasks_menu(self)
|
||||||
# elif choice == "additional":
|
|
||||||
# self.menus['additional'].show_additional_tasks_menu(self)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Invalid choice '{choice}' received from show_main_menu.")
|
logger.warning(f"Invalid choice '{choice}' received from show_main_menu.")
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Extracted from src.modules.menu_handler.MenuHandler.show_additional_tasks_menu()
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from jackify.shared.colors import (
|
from jackify.shared.colors import (
|
||||||
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_INFO, COLOR_DISABLED
|
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_INFO, COLOR_DISABLED, COLOR_WARNING
|
||||||
)
|
)
|
||||||
from jackify.shared.ui_utils import print_jackify_banner, print_section_header, clear_screen
|
from jackify.shared.ui_utils import print_jackify_banner, print_section_header, clear_screen
|
||||||
|
|
||||||
@@ -24,29 +24,26 @@ class AdditionalMenuHandler:
|
|||||||
clear_screen()
|
clear_screen()
|
||||||
|
|
||||||
def show_additional_tasks_menu(self, cli_instance):
|
def show_additional_tasks_menu(self, cli_instance):
|
||||||
"""Show the MO2, NXM Handling & Recovery submenu"""
|
"""Show the Additional Tasks & Tools submenu"""
|
||||||
while True:
|
while True:
|
||||||
self._clear_screen()
|
self._clear_screen()
|
||||||
print_jackify_banner()
|
print_jackify_banner()
|
||||||
print_section_header("Additional Utilities") # Broader title
|
print_section_header("Additional Tasks & Tools")
|
||||||
|
print(f"{COLOR_INFO}Additional Tasks & Tools, such as TTW Installation{COLOR_RESET}\n")
|
||||||
|
|
||||||
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install Mod Organizer 2 (Base Setup)")
|
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Tale of Two Wastelands (TTW) Installation")
|
||||||
print(f" {COLOR_ACTION}→ Proton setup for a standalone MO2 instance{COLOR_RESET}")
|
print(f" {COLOR_ACTION}→ Install TTW using Hoolamike native automation{COLOR_RESET}")
|
||||||
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure NXM Handling {COLOR_DISABLED}(Not Implemented){COLOR_RESET}")
|
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Coming Soon...")
|
||||||
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Jackify Recovery Tools")
|
|
||||||
print(f" {COLOR_ACTION}→ Restore files modified or backed up by Jackify{COLOR_RESET}")
|
|
||||||
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
|
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
|
||||||
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip()
|
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
|
||||||
|
|
||||||
if selection.lower() == 'q': # Allow 'q' to re-display menu
|
if selection.lower() == 'q': # Allow 'q' to re-display menu
|
||||||
continue
|
continue
|
||||||
if selection == "1":
|
if selection == "1":
|
||||||
self._execute_legacy_install_mo2(cli_instance)
|
self._execute_hoolamike_ttw_install(cli_instance)
|
||||||
elif selection == "2":
|
elif selection == "2":
|
||||||
print(f"{COLOR_INFO}Configure NXM Handling is not yet implemented.{COLOR_RESET}")
|
print(f"\n{COLOR_INFO}More features coming soon!{COLOR_RESET}")
|
||||||
input("\nPress Enter to return to the Utilities menu...")
|
input("\nPress Enter to return to menu...")
|
||||||
elif selection == "3":
|
|
||||||
self._execute_legacy_recovery_menu(cli_instance)
|
|
||||||
elif selection == "0":
|
elif selection == "0":
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -70,3 +67,58 @@ class AdditionalMenuHandler:
|
|||||||
recovery_handler = RecoveryMenuHandler()
|
recovery_handler = RecoveryMenuHandler()
|
||||||
recovery_handler.logger = self.logger
|
recovery_handler.logger = self.logger
|
||||||
recovery_handler.show_recovery_menu(cli_instance)
|
recovery_handler.show_recovery_menu(cli_instance)
|
||||||
|
|
||||||
|
def _execute_hoolamike_ttw_install(self, cli_instance):
|
||||||
|
"""Execute TTW installation using Hoolamike handler"""
|
||||||
|
from ....backend.handlers.hoolamike_handler import HoolamikeHandler
|
||||||
|
from ....backend.models.configuration import SystemInfo
|
||||||
|
from ....shared.colors import COLOR_ERROR
|
||||||
|
|
||||||
|
system_info = SystemInfo(is_steamdeck=cli_instance.system_info.is_steamdeck)
|
||||||
|
hoolamike_handler = HoolamikeHandler(
|
||||||
|
steamdeck=system_info.is_steamdeck,
|
||||||
|
verbose=cli_instance.verbose,
|
||||||
|
filesystem_handler=cli_instance.filesystem_handler,
|
||||||
|
config_handler=cli_instance.config_handler,
|
||||||
|
menu_handler=cli_instance.menu_handler
|
||||||
|
)
|
||||||
|
|
||||||
|
# First check if Hoolamike is installed
|
||||||
|
if not hoolamike_handler.hoolamike_installed:
|
||||||
|
print(f"\n{COLOR_WARNING}Hoolamike is not installed. Installing Hoolamike first...{COLOR_RESET}")
|
||||||
|
if not hoolamike_handler.install_update_hoolamike():
|
||||||
|
print(f"{COLOR_ERROR}Failed to install Hoolamike. Cannot proceed with TTW installation.{COLOR_RESET}")
|
||||||
|
input("Press Enter to return to menu...")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run TTW installation workflow
|
||||||
|
print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}")
|
||||||
|
result = hoolamike_handler.install_ttw()
|
||||||
|
if result is None:
|
||||||
|
print(f"\n{COLOR_WARNING}TTW installation returned without result.{COLOR_RESET}")
|
||||||
|
input("Press Enter to return to menu...")
|
||||||
|
|
||||||
|
def _execute_hoolamike_modlist_install(self, cli_instance):
|
||||||
|
"""Execute modlist installation using Hoolamike handler"""
|
||||||
|
from ....backend.handlers.hoolamike_handler import HoolamikeHandler
|
||||||
|
from ....backend.models.configuration import SystemInfo
|
||||||
|
|
||||||
|
system_info = SystemInfo(is_steamdeck=cli_instance.system_info.is_steamdeck)
|
||||||
|
hoolamike_handler = HoolamikeHandler(
|
||||||
|
steamdeck=system_info.is_steamdeck,
|
||||||
|
verbose=cli_instance.verbose,
|
||||||
|
filesystem_handler=cli_instance.filesystem_handler,
|
||||||
|
config_handler=cli_instance.config_handler,
|
||||||
|
menu_handler=cli_instance.menu_handler
|
||||||
|
)
|
||||||
|
|
||||||
|
# First check if Hoolamike is installed
|
||||||
|
if not hoolamike_handler.hoolamike_installed:
|
||||||
|
print(f"\n{COLOR_WARNING}Hoolamike is not installed. Installing Hoolamike first...{COLOR_RESET}")
|
||||||
|
if not hoolamike_handler.install_update_hoolamike():
|
||||||
|
print(f"{COLOR_ERROR}Failed to install Hoolamike. Cannot proceed with modlist installation.{COLOR_RESET}")
|
||||||
|
input("Press Enter to return to menu...")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run modlist installation
|
||||||
|
hoolamike_handler.install_modlist()
|
||||||
@@ -42,17 +42,9 @@ class MainMenuHandler:
|
|||||||
print(f"{COLOR_SELECTION}{'-'*22}{COLOR_RESET}") # Standard separator
|
print(f"{COLOR_SELECTION}{'-'*22}{COLOR_RESET}") # Standard separator
|
||||||
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Modlist Tasks")
|
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Modlist Tasks")
|
||||||
print(f" {COLOR_ACTION}→ Install & Configure Modlists{COLOR_RESET}")
|
print(f" {COLOR_ACTION}→ Install & Configure Modlists{COLOR_RESET}")
|
||||||
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Coming Soon...")
|
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Additional Tasks & Tools")
|
||||||
print(f" {COLOR_ACTION}→ More features coming in future releases{COLOR_RESET}")
|
print(f" {COLOR_ACTION}→ TTW automation, Wabbajack via Wine, MO2, NXM Handling, Recovery{COLOR_RESET}")
|
||||||
if self.dev_mode:
|
|
||||||
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Hoolamike Tasks")
|
|
||||||
print(f" {COLOR_ACTION}→ Wabbajack alternative: Install Modlists, TTW, etc{COLOR_RESET}")
|
|
||||||
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Additional Tasks")
|
|
||||||
print(f" {COLOR_ACTION}→ Install Wabbajack (via WINE), MO2, NXM Handling, Jackify Recovery{COLOR_RESET}")
|
|
||||||
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Exit Jackify")
|
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Exit Jackify")
|
||||||
if self.dev_mode:
|
|
||||||
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip()
|
|
||||||
else:
|
|
||||||
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
|
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
|
||||||
|
|
||||||
if choice.lower() == 'q': # Allow 'q' to re-display menu
|
if choice.lower() == 'q': # Allow 'q' to re-display menu
|
||||||
@@ -60,17 +52,6 @@ class MainMenuHandler:
|
|||||||
if choice == "1":
|
if choice == "1":
|
||||||
return "wabbajack"
|
return "wabbajack"
|
||||||
elif choice == "2":
|
elif choice == "2":
|
||||||
# Additional features are coming in future releases
|
|
||||||
print(f"\n{COLOR_PROMPT}Coming Soon!{COLOR_RESET}")
|
|
||||||
print(f"More features will be added in future releases.")
|
|
||||||
print(f"Please use 'Modlist Tasks' for all current functionality.")
|
|
||||||
print(f"Press Enter to continue...")
|
|
||||||
input()
|
|
||||||
continue # Return to main menu
|
|
||||||
if self.dev_mode:
|
|
||||||
if choice == "3":
|
|
||||||
return "hoolamike"
|
|
||||||
elif choice == "4":
|
|
||||||
return "additional"
|
return "additional"
|
||||||
elif choice == "0":
|
elif choice == "0":
|
||||||
return "exit"
|
return "exit"
|
||||||
|
|||||||
@@ -909,9 +909,10 @@ class JackifyMainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Create screens using refactored codebase
|
# Create screens using refactored codebase
|
||||||
from jackify.frontends.gui.screens import (
|
from jackify.frontends.gui.screens import (
|
||||||
MainMenu, ModlistTasksScreen,
|
MainMenu, ModlistTasksScreen, AdditionalTasksScreen,
|
||||||
InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen
|
InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen
|
||||||
)
|
)
|
||||||
|
from jackify.frontends.gui.screens.install_ttw import InstallTTWScreen
|
||||||
|
|
||||||
self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode)
|
self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode)
|
||||||
self.feature_placeholder = FeaturePlaceholder(stacked_widget=self.stacked_widget)
|
self.feature_placeholder = FeaturePlaceholder(stacked_widget=self.stacked_widget)
|
||||||
@@ -921,6 +922,11 @@ class JackifyMainWindow(QMainWindow):
|
|||||||
main_menu_index=0,
|
main_menu_index=0,
|
||||||
dev_mode=dev_mode
|
dev_mode=dev_mode
|
||||||
)
|
)
|
||||||
|
self.additional_tasks_screen = AdditionalTasksScreen(
|
||||||
|
stacked_widget=self.stacked_widget,
|
||||||
|
main_menu_index=0,
|
||||||
|
system_info=self.system_info
|
||||||
|
)
|
||||||
self.install_modlist_screen = InstallModlistScreen(
|
self.install_modlist_screen = InstallModlistScreen(
|
||||||
stacked_widget=self.stacked_widget,
|
stacked_widget=self.stacked_widget,
|
||||||
main_menu_index=0
|
main_menu_index=0
|
||||||
@@ -933,14 +939,26 @@ class JackifyMainWindow(QMainWindow):
|
|||||||
stacked_widget=self.stacked_widget,
|
stacked_widget=self.stacked_widget,
|
||||||
main_menu_index=0
|
main_menu_index=0
|
||||||
)
|
)
|
||||||
|
self.install_ttw_screen = InstallTTWScreen(
|
||||||
|
stacked_widget=self.stacked_widget,
|
||||||
|
main_menu_index=0,
|
||||||
|
system_info=self.system_info
|
||||||
|
)
|
||||||
|
# Let TTW screen request window resize for expand/collapse
|
||||||
|
try:
|
||||||
|
self.install_ttw_screen.resize_request.connect(self._on_child_resize_request)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Add screens to stacked widget
|
# Add screens to stacked widget
|
||||||
self.stacked_widget.addWidget(self.main_menu) # Index 0: Main Menu
|
self.stacked_widget.addWidget(self.main_menu) # Index 0: Main Menu
|
||||||
self.stacked_widget.addWidget(self.feature_placeholder) # Index 1: Placeholder
|
self.stacked_widget.addWidget(self.feature_placeholder) # Index 1: Placeholder
|
||||||
self.stacked_widget.addWidget(self.modlist_tasks_screen) # Index 2: Modlist Tasks
|
self.stacked_widget.addWidget(self.modlist_tasks_screen) # Index 2: Modlist Tasks
|
||||||
self.stacked_widget.addWidget(self.install_modlist_screen) # Index 3: Install Modlist
|
self.stacked_widget.addWidget(self.additional_tasks_screen) # Index 3: Additional Tasks
|
||||||
self.stacked_widget.addWidget(self.configure_new_modlist_screen) # Index 4: Configure New
|
self.stacked_widget.addWidget(self.install_modlist_screen) # Index 4: Install Modlist
|
||||||
self.stacked_widget.addWidget(self.configure_existing_modlist_screen) # Index 5: Configure Existing
|
self.stacked_widget.addWidget(self.install_ttw_screen) # Index 5: Install TTW
|
||||||
|
self.stacked_widget.addWidget(self.configure_new_modlist_screen) # Index 6: Configure New
|
||||||
|
self.stacked_widget.addWidget(self.configure_existing_modlist_screen) # Index 7: Configure Existing
|
||||||
|
|
||||||
# Add debug tracking for screen changes
|
# Add debug tracking for screen changes
|
||||||
self.stacked_widget.currentChanged.connect(self._debug_screen_change)
|
self.stacked_widget.currentChanged.connect(self._debug_screen_change)
|
||||||
@@ -1025,9 +1043,11 @@ class JackifyMainWindow(QMainWindow):
|
|||||||
0: "Main Menu",
|
0: "Main Menu",
|
||||||
1: "Feature Placeholder",
|
1: "Feature Placeholder",
|
||||||
2: "Modlist Tasks Menu",
|
2: "Modlist Tasks Menu",
|
||||||
3: "Install Modlist Screen",
|
3: "Additional Tasks Menu",
|
||||||
4: "Configure New Modlist",
|
4: "Install Modlist Screen",
|
||||||
5: "Configure Existing Modlist"
|
5: "Install TTW Screen",
|
||||||
|
6: "Configure New Modlist",
|
||||||
|
7: "Configure Existing Modlist"
|
||||||
}
|
}
|
||||||
screen_name = screen_names.get(index, f"Unknown Screen (Index {index})")
|
screen_name = screen_names.get(index, f"Unknown Screen (Index {index})")
|
||||||
widget = self.stacked_widget.widget(index)
|
widget = self.stacked_widget.widget(index)
|
||||||
@@ -1180,6 +1200,80 @@ class JackifyMainWindow(QMainWindow):
|
|||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
def _on_child_resize_request(self, mode: str):
|
||||||
|
debug_print(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}")
|
||||||
|
# On Steam Deck we keep the stable, full-size layout and ignore child resize
|
||||||
|
try:
|
||||||
|
if self.system_info and self.system_info.is_steamdeck:
|
||||||
|
debug_print("DEBUG: Steam Deck detected, ignoring resize request")
|
||||||
|
# Hide the checkbox if present (Deck uses full layout)
|
||||||
|
try:
|
||||||
|
if hasattr(self, 'install_ttw_screen') and self.install_ttw_screen.show_details_checkbox:
|
||||||
|
self.install_ttw_screen.show_details_checkbox.setVisible(False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Ensure we can actually resize
|
||||||
|
self.showNormal()
|
||||||
|
self.setMaximumHeight(16777215)
|
||||||
|
debug_print(f"DEBUG: Set max height to unlimited, current_size={self.size()}")
|
||||||
|
|
||||||
|
if mode == 'expand':
|
||||||
|
# Restore a sensible minimum and expand height
|
||||||
|
min_width = max(1200, self.minimumWidth())
|
||||||
|
min_height = 900
|
||||||
|
debug_print(f"DEBUG: Expand mode - min_width={min_width}, min_height={min_height}")
|
||||||
|
try:
|
||||||
|
from PySide6.QtCore import QSize
|
||||||
|
self.setMinimumSize(QSize(min_width, min_height))
|
||||||
|
except Exception:
|
||||||
|
self.setMinimumSize(min_width, min_height)
|
||||||
|
# Animate to target height
|
||||||
|
target_height = max(self.size().height(), min_height)
|
||||||
|
self._animate_height(target_height)
|
||||||
|
else:
|
||||||
|
# Collapse to compact height computed from the TTW screen's sizeHint
|
||||||
|
try:
|
||||||
|
content_hint = self.install_ttw_screen.sizeHint().height()
|
||||||
|
except Exception:
|
||||||
|
content_hint = 460
|
||||||
|
compact_height = max(440, min(560, content_hint + 20))
|
||||||
|
debug_print(f"DEBUG: Collapse mode - content_hint={content_hint}, compact_height={compact_height}")
|
||||||
|
from PySide6.QtCore import QSize
|
||||||
|
self.setMaximumHeight(compact_height)
|
||||||
|
self.setMinimumSize(QSize(max(1200, self.minimumWidth()), compact_height))
|
||||||
|
# Animate to compact height
|
||||||
|
self._animate_height(compact_height)
|
||||||
|
|
||||||
|
def _animate_height(self, target_height: int, duration_ms: int = 180):
|
||||||
|
"""Smoothly animate the window height to target_height.
|
||||||
|
|
||||||
|
Kept local imports to minimize global impact and avoid touching module headers.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QRect
|
||||||
|
except Exception:
|
||||||
|
# Fallback to immediate resize if animation types are unavailable
|
||||||
|
before = self.size()
|
||||||
|
self.resize(self.size().width(), target_height)
|
||||||
|
debug_print(f"DEBUG: Animated fallback resize from {before} to {self.size()}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build end rect with same x/y/width and target height
|
||||||
|
start_rect = self.geometry()
|
||||||
|
end_rect = QRect(start_rect.x(), start_rect.y(), start_rect.width(), target_height)
|
||||||
|
|
||||||
|
# Hold reference to avoid GC stopping the animation
|
||||||
|
self._resize_anim = QPropertyAnimation(self, b"geometry")
|
||||||
|
self._resize_anim.setDuration(duration_ms)
|
||||||
|
self._resize_anim.setEasingCurve(QEasingCurve.OutCubic)
|
||||||
|
self._resize_anim.setStartValue(start_rect)
|
||||||
|
self._resize_anim.setEndValue(end_rect)
|
||||||
|
self._resize_anim.start()
|
||||||
|
|
||||||
|
|
||||||
def resource_path(relative_path):
|
def resource_path(relative_path):
|
||||||
if hasattr(sys, '_MEIPASS'):
|
if hasattr(sys, '_MEIPASS'):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Contains all the GUI screen components for Jackify.
|
|||||||
|
|
||||||
from .main_menu import MainMenu
|
from .main_menu import MainMenu
|
||||||
from .modlist_tasks import ModlistTasksScreen
|
from .modlist_tasks import ModlistTasksScreen
|
||||||
|
from .additional_tasks import AdditionalTasksScreen
|
||||||
from .install_modlist import InstallModlistScreen
|
from .install_modlist import InstallModlistScreen
|
||||||
from .configure_new_modlist import ConfigureNewModlistScreen
|
from .configure_new_modlist import ConfigureNewModlistScreen
|
||||||
from .configure_existing_modlist import ConfigureExistingModlistScreen
|
from .configure_existing_modlist import ConfigureExistingModlistScreen
|
||||||
@@ -13,6 +14,7 @@ from .configure_existing_modlist import ConfigureExistingModlistScreen
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
'MainMenu',
|
'MainMenu',
|
||||||
'ModlistTasksScreen',
|
'ModlistTasksScreen',
|
||||||
|
'AdditionalTasksScreen',
|
||||||
'InstallModlistScreen',
|
'InstallModlistScreen',
|
||||||
'ConfigureNewModlistScreen',
|
'ConfigureNewModlistScreen',
|
||||||
'ConfigureExistingModlistScreen'
|
'ConfigureExistingModlistScreen'
|
||||||
|
|||||||
169
jackify/frontends/gui/screens/additional_tasks.py
Normal file
169
jackify/frontends/gui/screens/additional_tasks.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Additional Tasks & Tools Screen
|
||||||
|
|
||||||
|
Simple screen for TTW automation only.
|
||||||
|
Follows the same pattern as ModlistTasksScreen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||||
|
QGridLayout
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
|
||||||
|
from jackify.backend.models.configuration import SystemInfo
|
||||||
|
from ..shared_theme import JACKIFY_COLOR_BLUE
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AdditionalTasksScreen(QWidget):
|
||||||
|
"""Simple Additional Tasks screen for TTW only"""
|
||||||
|
|
||||||
|
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None):
|
||||||
|
super().__init__()
|
||||||
|
self.stacked_widget = stacked_widget
|
||||||
|
self.main_menu_index = main_menu_index
|
||||||
|
self.system_info = system_info or SystemInfo(is_steamdeck=False)
|
||||||
|
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
"""Set up the user interface following ModlistTasksScreen pattern"""
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.setContentsMargins(40, 40, 40, 40)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Header section
|
||||||
|
self._setup_header(layout)
|
||||||
|
|
||||||
|
# Menu buttons section
|
||||||
|
self._setup_menu_buttons(layout)
|
||||||
|
|
||||||
|
# Bottom spacer
|
||||||
|
layout.addStretch()
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def _setup_header(self, layout):
|
||||||
|
"""Set up the header section"""
|
||||||
|
header_layout = QVBoxLayout()
|
||||||
|
header_layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
title = QLabel("<b>Additional Tasks & Tools</b>")
|
||||||
|
title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};")
|
||||||
|
title.setAlignment(Qt.AlignHCenter)
|
||||||
|
header_layout.addWidget(title)
|
||||||
|
|
||||||
|
# Add a spacer to match main menu vertical spacing
|
||||||
|
header_layout.addSpacing(16)
|
||||||
|
|
||||||
|
# Description
|
||||||
|
desc = QLabel(
|
||||||
|
"TTW automation and additional tools.<br> "
|
||||||
|
)
|
||||||
|
desc.setWordWrap(True)
|
||||||
|
desc.setStyleSheet("color: #ccc;")
|
||||||
|
desc.setAlignment(Qt.AlignHCenter)
|
||||||
|
header_layout.addWidget(desc)
|
||||||
|
|
||||||
|
header_layout.addSpacing(24)
|
||||||
|
|
||||||
|
# Separator (shorter like main menu)
|
||||||
|
sep = QLabel()
|
||||||
|
sep.setFixedHeight(2)
|
||||||
|
sep.setFixedWidth(400) # Match button width
|
||||||
|
sep.setStyleSheet("background: #fff;")
|
||||||
|
header_layout.addWidget(sep, alignment=Qt.AlignHCenter)
|
||||||
|
|
||||||
|
header_layout.addSpacing(16)
|
||||||
|
layout.addLayout(header_layout)
|
||||||
|
|
||||||
|
def _setup_menu_buttons(self, layout):
|
||||||
|
"""Set up the menu buttons section"""
|
||||||
|
# Menu options - ONLY TTW and placeholder
|
||||||
|
MENU_ITEMS = [
|
||||||
|
("Install TTW", "ttw_install", "Install Tale of Two Wastelands using Hoolamike automation"),
|
||||||
|
("Coming Soon...", "coming_soon", "Additional tools will be added in future updates"),
|
||||||
|
("Return to Main Menu", "return_main_menu", "Go back to the main menu"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create grid layout for buttons (mirror ModlistTasksScreen pattern)
|
||||||
|
button_grid = QGridLayout()
|
||||||
|
button_grid.setSpacing(16)
|
||||||
|
button_grid.setAlignment(Qt.AlignHCenter)
|
||||||
|
|
||||||
|
button_width = 400
|
||||||
|
button_height = 50
|
||||||
|
|
||||||
|
for i, (label, action_id, description) in enumerate(MENU_ITEMS):
|
||||||
|
# Create button
|
||||||
|
btn = QPushButton(label)
|
||||||
|
btn.setFixedSize(button_width, button_height)
|
||||||
|
btn.setStyleSheet(f"""
|
||||||
|
QPushButton {{
|
||||||
|
background-color: #4a5568;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{
|
||||||
|
background-color: #5a6578;
|
||||||
|
}}
|
||||||
|
QPushButton:pressed {{
|
||||||
|
background-color: {JACKIFY_COLOR_BLUE};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
btn.clicked.connect(lambda checked, a=action_id: self._handle_button_click(a))
|
||||||
|
|
||||||
|
# Description label
|
||||||
|
desc_label = QLabel(description)
|
||||||
|
desc_label.setAlignment(Qt.AlignHCenter)
|
||||||
|
desc_label.setStyleSheet("color: #999; font-size: 12px;")
|
||||||
|
desc_label.setWordWrap(True)
|
||||||
|
desc_label.setFixedWidth(button_width)
|
||||||
|
|
||||||
|
# Add to grid (button row, then description row)
|
||||||
|
button_grid.addWidget(btn, i * 2, 0, Qt.AlignHCenter)
|
||||||
|
button_grid.addWidget(desc_label, i * 2 + 1, 0, Qt.AlignHCenter)
|
||||||
|
|
||||||
|
layout.addLayout(button_grid)
|
||||||
|
|
||||||
|
# Removed _create_menu_button; using same pattern as ModlistTasksScreen
|
||||||
|
|
||||||
|
def _handle_button_click(self, action_id):
|
||||||
|
"""Handle button clicks"""
|
||||||
|
if action_id == "ttw_install":
|
||||||
|
self._show_ttw_info()
|
||||||
|
elif action_id == "coming_soon":
|
||||||
|
self._show_coming_soon_info()
|
||||||
|
elif action_id == "return_main_menu":
|
||||||
|
self._return_to_main_menu()
|
||||||
|
|
||||||
|
def _show_ttw_info(self):
|
||||||
|
"""Navigate to TTW installation screen"""
|
||||||
|
if self.stacked_widget:
|
||||||
|
# Navigate to TTW installation screen (index 5)
|
||||||
|
self.stacked_widget.setCurrentIndex(5)
|
||||||
|
|
||||||
|
def _show_coming_soon_info(self):
|
||||||
|
"""Show coming soon info"""
|
||||||
|
from ..services.message_service import MessageService
|
||||||
|
MessageService.information(
|
||||||
|
self,
|
||||||
|
"Coming Soon",
|
||||||
|
"Additional tools and features will be added in future updates.\n\n"
|
||||||
|
"Check back later for more functionality!"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _return_to_main_menu(self):
|
||||||
|
"""Return to main menu"""
|
||||||
|
if self.stacked_widget:
|
||||||
|
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
||||||
@@ -412,6 +412,9 @@ class ConfigureExistingModlistScreen(QWidget):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def validate_and_start_configure(self):
|
def validate_and_start_configure(self):
|
||||||
|
# Reload config to pick up any settings changes made in Settings dialog
|
||||||
|
self.config_handler.reload_config()
|
||||||
|
|
||||||
# Rotate log file at start of each workflow run (keep 5 backups)
|
# Rotate log file at start of each workflow run (keep 5 backups)
|
||||||
from jackify.backend.handlers.logging_handler import LoggingHandler
|
from jackify.backend.handlers.logging_handler import LoggingHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -453,6 +456,10 @@ class ConfigureExistingModlistScreen(QWidget):
|
|||||||
|
|
||||||
def start_workflow(self, modlist_name, install_dir, resolution):
|
def start_workflow(self, modlist_name, install_dir, resolution):
|
||||||
"""Start the configuration workflow using backend service directly"""
|
"""Start the configuration workflow using backend service directly"""
|
||||||
|
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
|
||||||
|
# This ensures Proton version and winetricks settings are current
|
||||||
|
self.config_handler._load_config()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start time tracking
|
# Start time tracking
|
||||||
self._workflow_start_time = time.time()
|
self._workflow_start_time = time.time()
|
||||||
|
|||||||
@@ -554,6 +554,9 @@ class ConfigureNewModlistScreen(QWidget):
|
|||||||
return True # Continue anyway
|
return True # Continue anyway
|
||||||
|
|
||||||
def validate_and_start_configure(self):
|
def validate_and_start_configure(self):
|
||||||
|
# Reload config to pick up any settings changes made in Settings dialog
|
||||||
|
self.config_handler.reload_config()
|
||||||
|
|
||||||
# Check protontricks before proceeding
|
# Check protontricks before proceeding
|
||||||
if not self._check_protontricks():
|
if not self._check_protontricks():
|
||||||
return
|
return
|
||||||
@@ -665,6 +668,10 @@ class ConfigureNewModlistScreen(QWidget):
|
|||||||
MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.", safety_level="medium")
|
MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.", safety_level="medium")
|
||||||
|
|
||||||
def configure_modlist(self):
|
def configure_modlist(self):
|
||||||
|
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
|
||||||
|
# This ensures Proton version and winetricks settings are current
|
||||||
|
self.config_handler._load_config()
|
||||||
|
|
||||||
install_dir = os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip()
|
install_dir = os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip()
|
||||||
modlist_name = self.modlist_name_edit.text().strip()
|
modlist_name = self.modlist_name_edit.text().strip()
|
||||||
mo2_exe_path = self.install_dir_edit.text().strip()
|
mo2_exe_path = self.install_dir_edit.text().strip()
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ class InstallModlistScreen(QWidget):
|
|||||||
header_layout.addWidget(title)
|
header_layout.addWidget(title)
|
||||||
# Description
|
# Description
|
||||||
desc = QLabel(
|
desc = QLabel(
|
||||||
"This screen allows you to install a Wabbajack modlist using Jackify's native Linux tools. "
|
"This screen allows you to install a Wabbajack modlist using Jackify. "
|
||||||
"Configure your options and start the installation."
|
"Configure your options and start the installation."
|
||||||
)
|
)
|
||||||
desc.setWordWrap(True)
|
desc.setWordWrap(True)
|
||||||
@@ -1072,7 +1072,8 @@ class InstallModlistScreen(QWidget):
|
|||||||
line_lower = line.lower()
|
line_lower = line.lower()
|
||||||
if (
|
if (
|
||||||
("jackify-engine" in line_lower or "7zz" in line_lower or "texconv" in line_lower or
|
("jackify-engine" in line_lower or "7zz" in line_lower or "texconv" in line_lower or
|
||||||
"wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower)
|
"wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower or
|
||||||
|
"hoolamike" in line_lower)
|
||||||
and "jackify-gui.py" not in line_lower
|
and "jackify-gui.py" not in line_lower
|
||||||
):
|
):
|
||||||
cols = line.strip().split(None, 3)
|
cols = line.strip().split(None, 3)
|
||||||
@@ -1114,6 +1115,175 @@ class InstallModlistScreen(QWidget):
|
|||||||
"Continuing anyway, but some features may not work correctly.")
|
"Continuing anyway, but some features may not work correctly.")
|
||||||
return True # Continue anyway
|
return True # Continue anyway
|
||||||
|
|
||||||
|
def _check_ttw_eligibility(self, modlist_name: str, game_type: str, install_dir: str) -> bool:
|
||||||
|
"""Check if modlist is FNV, TTW-compatible, and doesn't already have TTW
|
||||||
|
|
||||||
|
Args:
|
||||||
|
modlist_name: Name of the installed modlist
|
||||||
|
game_type: Game type (e.g., 'falloutnv')
|
||||||
|
install_dir: Modlist installation directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if should offer TTW integration
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check 1: Must be Fallout New Vegas
|
||||||
|
if game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check 2: Must be on whitelist
|
||||||
|
from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible
|
||||||
|
if not is_ttw_compatible(modlist_name):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check 3: TTW must not already be installed
|
||||||
|
if self._detect_existing_ttw(install_dir):
|
||||||
|
debug_print("DEBUG: TTW already installed, skipping prompt")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
debug_print(f"DEBUG: Error checking TTW eligibility: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _detect_existing_ttw(self, install_dir: str) -> bool:
|
||||||
|
"""Check if TTW is already installed in the modlist
|
||||||
|
|
||||||
|
Args:
|
||||||
|
install_dir: Modlist installation directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if TTW is already present
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
mods_dir = Path(install_dir) / "mods"
|
||||||
|
if not mods_dir.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for folders containing "Tale of Two Wastelands" that have actual TTW content
|
||||||
|
# Exclude separators and placeholder folders
|
||||||
|
for folder in mods_dir.iterdir():
|
||||||
|
if not folder.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
folder_name_lower = folder.name.lower()
|
||||||
|
|
||||||
|
# Skip separator folders and placeholders
|
||||||
|
if "_separator" in folder_name_lower or "put" in folder_name_lower or "here" in folder_name_lower:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if folder name contains TTW indicator
|
||||||
|
if "tale of two wastelands" in folder_name_lower:
|
||||||
|
# Verify it has actual TTW content by checking for the main ESM
|
||||||
|
ttw_esm = folder / "TaleOfTwoWastelands.esm"
|
||||||
|
if ttw_esm.exists():
|
||||||
|
debug_print(f"DEBUG: Found existing TTW installation: {folder.name}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
debug_print(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
debug_print(f"DEBUG: Error detecting existing TTW: {e}")
|
||||||
|
return False # Assume not installed on error
|
||||||
|
|
||||||
|
def _initiate_ttw_workflow(self, modlist_name: str, install_dir: str):
|
||||||
|
"""Navigate to TTW screen and set it up for modlist integration
|
||||||
|
|
||||||
|
Args:
|
||||||
|
modlist_name: Name of the modlist that needs TTW integration
|
||||||
|
install_dir: Path to the modlist installation directory
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Store modlist context for later use when TTW completes
|
||||||
|
self._ttw_modlist_name = modlist_name
|
||||||
|
self._ttw_install_dir = install_dir
|
||||||
|
|
||||||
|
# Get reference to TTW screen BEFORE navigation
|
||||||
|
if self.stacked_widget:
|
||||||
|
ttw_screen = self.stacked_widget.widget(5)
|
||||||
|
|
||||||
|
# Set integration mode BEFORE navigating to avoid showEvent race condition
|
||||||
|
if hasattr(ttw_screen, 'set_modlist_integration_mode'):
|
||||||
|
ttw_screen.set_modlist_integration_mode(modlist_name, install_dir)
|
||||||
|
|
||||||
|
# Connect to completion signal to show success dialog after TTW
|
||||||
|
if hasattr(ttw_screen, 'integration_complete'):
|
||||||
|
ttw_screen.integration_complete.connect(self._on_ttw_integration_complete)
|
||||||
|
else:
|
||||||
|
debug_print("WARNING: TTW screen does not support modlist integration mode yet")
|
||||||
|
|
||||||
|
# Navigate to TTW screen AFTER setting integration mode
|
||||||
|
self.stacked_widget.setCurrentIndex(5)
|
||||||
|
|
||||||
|
# Force collapsed state shortly after navigation to avoid any
|
||||||
|
# showEvent/layout timing races that may leave it expanded
|
||||||
|
try:
|
||||||
|
from PySide6.QtCore import QTimer
|
||||||
|
QTimer.singleShot(50, lambda: getattr(ttw_screen, 'force_collapsed_state', lambda: None)())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
debug_print(f"ERROR: Failed to initiate TTW workflow: {e}")
|
||||||
|
MessageService.critical(
|
||||||
|
self,
|
||||||
|
"TTW Navigation Failed",
|
||||||
|
f"Failed to navigate to TTW installation screen: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_ttw_integration_complete(self, success: bool, ttw_version: str = ""):
|
||||||
|
"""Handle completion of TTW integration and show final success dialog
|
||||||
|
|
||||||
|
Args:
|
||||||
|
success: Whether TTW integration completed successfully
|
||||||
|
ttw_version: Version of TTW that was installed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not success:
|
||||||
|
MessageService.critical(
|
||||||
|
self,
|
||||||
|
"TTW Integration Failed",
|
||||||
|
"Tale of Two Wastelands integration did not complete successfully."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Navigate back to this screen to show success dialog
|
||||||
|
if self.stacked_widget:
|
||||||
|
self.stacked_widget.setCurrentIndex(4)
|
||||||
|
|
||||||
|
# Build success message including TTW installation
|
||||||
|
modlist_name = getattr(self, '_ttw_modlist_name', 'Unknown')
|
||||||
|
time_str = getattr(self, '_elapsed_time_str', '0m 0s')
|
||||||
|
game_name = "Fallout New Vegas"
|
||||||
|
|
||||||
|
# Show enhanced success dialog
|
||||||
|
success_dialog = SuccessDialog(
|
||||||
|
modlist_name=modlist_name,
|
||||||
|
workflow_type="install",
|
||||||
|
time_taken=time_str,
|
||||||
|
game_name=game_name,
|
||||||
|
parent=self
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add TTW installation info to dialog if possible
|
||||||
|
if hasattr(success_dialog, 'add_info_line'):
|
||||||
|
success_dialog.add_info_line(f"TTW {ttw_version} integrated successfully")
|
||||||
|
|
||||||
|
success_dialog.show()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
debug_print(f"ERROR: Failed to show final success dialog: {e}")
|
||||||
|
MessageService.critical(
|
||||||
|
self,
|
||||||
|
"Display Error",
|
||||||
|
f"TTW integration completed but failed to show success dialog: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
def _on_api_key_save_toggled(self, checked):
|
def _on_api_key_save_toggled(self, checked):
|
||||||
"""Handle immediate API key saving with silent validation when checkbox is toggled"""
|
"""Handle immediate API key saving with silent validation when checkbox is toggled"""
|
||||||
try:
|
try:
|
||||||
@@ -1189,6 +1359,9 @@ class InstallModlistScreen(QWidget):
|
|||||||
self._install_workflow_start_time = time.time()
|
self._install_workflow_start_time = time.time()
|
||||||
debug_print('DEBUG: validate_and_start_install called')
|
debug_print('DEBUG: validate_and_start_install called')
|
||||||
|
|
||||||
|
# Reload config to pick up any settings changes made in Settings dialog
|
||||||
|
self.config_handler.reload_config()
|
||||||
|
|
||||||
# Check protontricks before proceeding
|
# Check protontricks before proceeding
|
||||||
if not self._check_protontricks():
|
if not self._check_protontricks():
|
||||||
return
|
return
|
||||||
@@ -1764,6 +1937,11 @@ class InstallModlistScreen(QWidget):
|
|||||||
MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.")
|
MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.")
|
||||||
|
|
||||||
def start_automated_prefix_workflow(self):
|
def start_automated_prefix_workflow(self):
|
||||||
|
"""Start the automated prefix creation workflow"""
|
||||||
|
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
|
||||||
|
# This ensures Proton version and winetricks settings are current
|
||||||
|
self.config_handler._load_config()
|
||||||
|
|
||||||
# Ensure _current_resolution is always set before starting workflow
|
# Ensure _current_resolution is always set before starting workflow
|
||||||
if not hasattr(self, '_current_resolution') or self._current_resolution is None:
|
if not hasattr(self, '_current_resolution') or self._current_resolution is None:
|
||||||
resolution = self.resolution_combo.currentText() if hasattr(self, 'resolution_combo') else None
|
resolution = self.resolution_combo.currentText() if hasattr(self, 'resolution_combo') else None
|
||||||
@@ -1775,7 +1953,7 @@ class InstallModlistScreen(QWidget):
|
|||||||
self._current_resolution = resolution
|
self._current_resolution = resolution
|
||||||
else:
|
else:
|
||||||
self._current_resolution = None
|
self._current_resolution = None
|
||||||
"""Start the automated prefix creation workflow"""
|
|
||||||
try:
|
try:
|
||||||
# Disable controls during installation
|
# Disable controls during installation
|
||||||
self._disable_controls_during_operation()
|
self._disable_controls_during_operation()
|
||||||
@@ -2002,6 +2180,31 @@ class InstallModlistScreen(QWidget):
|
|||||||
'enderal': 'Enderal'
|
'enderal': 'Enderal'
|
||||||
}
|
}
|
||||||
game_name = display_names.get(self._current_game_type, self._current_game_name)
|
game_name = display_names.get(self._current_game_type, self._current_game_name)
|
||||||
|
|
||||||
|
# Check for TTW eligibility before showing final success dialog
|
||||||
|
install_dir = self.install_dir_edit.text().strip()
|
||||||
|
if self._check_ttw_eligibility(modlist_name, self._current_game_type, install_dir):
|
||||||
|
# Offer TTW installation
|
||||||
|
reply = MessageService.question(
|
||||||
|
self,
|
||||||
|
"Install TTW?",
|
||||||
|
f"{modlist_name} requires Tale of Two Wastelands!\n\n"
|
||||||
|
"Would you like to install and configure TTW automatically now?\n\n"
|
||||||
|
"This will:\n"
|
||||||
|
"• Guide you through TTW installation\n"
|
||||||
|
"• Automatically integrate TTW into your modlist\n"
|
||||||
|
"• Configure load order correctly\n\n"
|
||||||
|
"Note: TTW installation can take a while. You can also install TTW later from Additional Tasks & Tools.",
|
||||||
|
critical=False,
|
||||||
|
safety_level="medium"
|
||||||
|
)
|
||||||
|
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
# Navigate to TTW screen
|
||||||
|
self._initiate_ttw_workflow(modlist_name, install_dir)
|
||||||
|
return # Don't show success dialog yet, will show after TTW completes
|
||||||
|
|
||||||
|
# Show normal success dialog
|
||||||
success_dialog = SuccessDialog(
|
success_dialog = SuccessDialog(
|
||||||
modlist_name=modlist_name,
|
modlist_name=modlist_name,
|
||||||
workflow_type="install",
|
workflow_type="install",
|
||||||
@@ -2747,7 +2950,4 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html</i>"""
|
|||||||
# Re-enable controls (in case they were disabled from previous errors)
|
# Re-enable controls (in case they were disabled from previous errors)
|
||||||
self._enable_controls_after_operation()
|
self._enable_controls_after_operation()
|
||||||
|
|
||||||
def closeEvent(self, event):
|
|
||||||
"""Handle window close event - clean up processes"""
|
|
||||||
self.cleanup_processes()
|
|
||||||
event.accept()
|
|
||||||
2932
jackify/frontends/gui/screens/install_ttw.py
Normal file
2932
jackify/frontends/gui/screens/install_ttw.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -47,12 +47,9 @@ class MainMenu(QWidget):
|
|||||||
button_height = 60
|
button_height = 60
|
||||||
MENU_ITEMS = [
|
MENU_ITEMS = [
|
||||||
("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"),
|
("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"),
|
||||||
("Coming Soon...", "coming_soon", "More features coming soon!"),
|
("Additional Tasks", "additional_tasks", "Additional Tasks & Tools, such as TTW Installation"),
|
||||||
|
("Exit Jackify", "exit_jackify", "Close the application"),
|
||||||
]
|
]
|
||||||
if self.dev_mode:
|
|
||||||
MENU_ITEMS.append(("Hoolamike Tasks", "hoolamike_tasks", "Manage Hoolamike modding tools"))
|
|
||||||
MENU_ITEMS.append(("Additional Tasks", "additional_tasks", "Additional utilities and tools"))
|
|
||||||
MENU_ITEMS.append(("Exit Jackify", "exit_jackify", "Close the application"))
|
|
||||||
|
|
||||||
for label, action_id, description in MENU_ITEMS:
|
for label, action_id, description in MENU_ITEMS:
|
||||||
# Main button
|
# Main button
|
||||||
@@ -121,8 +118,10 @@ class MainMenu(QWidget):
|
|||||||
msg.exec()
|
msg.exec()
|
||||||
elif action_id == "modlist_tasks" and self.stacked_widget:
|
elif action_id == "modlist_tasks" and self.stacked_widget:
|
||||||
self.stacked_widget.setCurrentIndex(2)
|
self.stacked_widget.setCurrentIndex(2)
|
||||||
|
elif action_id == "additional_tasks" and self.stacked_widget:
|
||||||
|
self.stacked_widget.setCurrentIndex(3)
|
||||||
elif action_id == "return_main_menu":
|
elif action_id == "return_main_menu":
|
||||||
# This is the main menu, so do nothing
|
# This is the main menu, so do nothing
|
||||||
pass
|
pass
|
||||||
elif self.stacked_widget:
|
elif self.stacked_widget:
|
||||||
self.stacked_widget.setCurrentIndex(2) # Placeholder for now
|
self.stacked_widget.setCurrentIndex(1) # Default to placeholder
|
||||||
@@ -198,11 +198,11 @@ class ModlistTasksScreen(QWidget):
|
|||||||
if action_id == "return_main_menu":
|
if action_id == "return_main_menu":
|
||||||
self.stacked_widget.setCurrentIndex(0)
|
self.stacked_widget.setCurrentIndex(0)
|
||||||
elif action_id == "install_modlist":
|
elif action_id == "install_modlist":
|
||||||
self.stacked_widget.setCurrentIndex(3)
|
self.stacked_widget.setCurrentIndex(4) # Install Modlist Screen
|
||||||
elif action_id == "configure_new_modlist":
|
elif action_id == "configure_new_modlist":
|
||||||
self.stacked_widget.setCurrentIndex(4)
|
self.stacked_widget.setCurrentIndex(6) # Configure New Modlist Screen
|
||||||
elif action_id == "configure_existing_modlist":
|
elif action_id == "configure_existing_modlist":
|
||||||
self.stacked_widget.setCurrentIndex(5)
|
self.stacked_widget.setCurrentIndex(7) # Configure Existing Modlist Screen
|
||||||
|
|
||||||
def go_back(self):
|
def go_back(self):
|
||||||
"""Return to main menu"""
|
"""Return to main menu"""
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ class MessageService:
|
|||||||
msg_box = MessageService._create_base_message_box(parent, critical, safety_level)
|
msg_box = MessageService._create_base_message_box(parent, critical, safety_level)
|
||||||
msg_box.setIcon(QMessageBox.Information)
|
msg_box.setIcon(QMessageBox.Information)
|
||||||
msg_box.setWindowTitle(title)
|
msg_box.setWindowTitle(title)
|
||||||
|
msg_box.setTextFormat(Qt.RichText)
|
||||||
|
msg_box.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||||
msg_box.setText(message)
|
msg_box.setText(message)
|
||||||
msg_box.setStandardButtons(buttons)
|
msg_box.setStandardButtons(buttons)
|
||||||
msg_box.setDefaultButton(default_button)
|
msg_box.setDefaultButton(default_button)
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ ANSI_COLOR_MAP = {
|
|||||||
}
|
}
|
||||||
ANSI_RE = re.compile(r'\x1b\[(\d+)(;\d+)?m')
|
ANSI_RE = re.compile(r'\x1b\[(\d+)(;\d+)?m')
|
||||||
|
|
||||||
|
# Pattern to match terminal control codes (cursor movement, line clearing, etc.)
|
||||||
|
ANSI_CONTROL_RE = re.compile(
|
||||||
|
r'\x1b\[' # CSI sequence start
|
||||||
|
r'[0-9;]*' # Parameters
|
||||||
|
r'[A-Za-z]' # Command letter
|
||||||
|
)
|
||||||
|
|
||||||
|
def strip_ansi_control_codes(text):
|
||||||
|
"""Remove ALL ANSI escape sequences including control codes.
|
||||||
|
|
||||||
|
This is useful for Hoolamike output which uses terminal control codes
|
||||||
|
for progress bars that don't render well in QTextEdit.
|
||||||
|
"""
|
||||||
|
return ANSI_CONTROL_RE.sub('', text)
|
||||||
|
|
||||||
def ansi_to_html(text):
|
def ansi_to_html(text):
|
||||||
"""Convert ANSI color codes to HTML"""
|
"""Convert ANSI color codes to HTML"""
|
||||||
result = ''
|
result = ''
|
||||||
|
|||||||
Reference in New Issue
Block a user