Sync from development - prepare for v0.2.1.1

This commit is contained in:
Omni
2026-01-15 18:06:02 +00:00
parent 29e1800074
commit 02f3d71a82
22 changed files with 803 additions and 193 deletions

View File

@@ -721,59 +721,75 @@ class FileSystemHandler:
return True
@staticmethod
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
"""Change ownership and permissions using sudo (robust, with timeout and re-prompt)."""
def verify_ownership_and_permissions(path: Path) -> tuple[bool, str]:
"""
Verify and fix ownership/permissions for modlist directory.
Returns (success, error_message).
Logic:
- If files NOT owned by user: Can't fix without sudo, return error with instructions
- If files owned by user: Try to fix permissions ourselves with chmod
"""
if not path.exists():
logger.error(f"Path does not exist: {path}")
return False
# Check if all files/dirs are already owned by the user
if FileSystemHandler.all_owned_by_user(path):
logger.info(f"All files in {path} are already owned by the current user. Skipping sudo chown/chmod.")
return True
return False, f"Path does not exist: {path}"
# Check if all files/dirs are owned by the user
if not FileSystemHandler.all_owned_by_user(path):
# Files not owned by us - need sudo to fix
try:
user_name = pwd.getpwuid(os.geteuid()).pw_name
group_name = grp.getgrgid(os.geteuid()).gr_name
except KeyError:
logger.error("Could not determine current user or group name.")
return False, "Could not determine current user or group name."
logger.error(f"Ownership issue detected: Some files in {path} are not owned by {user_name}")
error_msg = (
f"\nOwnership Issue Detected\n"
f"Some files in the modlist directory are not owned by your user account.\n"
f"This can happen if the modlist was copied from another location or installed by a different user.\n\n"
f"To fix this, open a terminal and run:\n\n"
f" sudo chown -R {user_name}:{group_name} \"{path}\"\n"
f" sudo chmod -R 755 \"{path}\"\n\n"
f"After running these commands, retry the configuration process."
)
return False, error_msg
# Files are owned by us - try to fix permissions ourselves
logger.info(f"Files in {path} are owned by current user, verifying permissions...")
try:
user_name = pwd.getpwuid(os.geteuid()).pw_name
group_name = grp.getgrgid(os.geteuid()).gr_name
except KeyError:
logger.error("Could not determine current user or group name.")
return False
result = subprocess.run(
['chmod', '-R', '755', str(path)],
capture_output=True,
text=True,
check=False
)
if result.returncode == 0:
logger.info(f"Permissions set successfully for {path}")
return True, ""
else:
logger.warning(f"chmod returned non-zero but we'll continue: {result.stderr}")
# Non-critical if chmod fails on our own files, might be read-only filesystem or similar
return True, ""
except Exception as e:
logger.warning(f"Error running chmod: {e}, continuing anyway")
# Non-critical error, we own the files so proceed
return True, ""
log_msg = f"Applying ownership/permissions for {path} (user: {user_name}, group: {group_name}) via sudo."
logger.info(log_msg)
if status_callback:
status_callback(f"Setting ownership/permissions for {os.path.basename(str(path))}...")
else:
print(f'\n{COLOR_PROMPT}Adjusting permissions for {path} (may require sudo password)...{COLOR_RESET}')
def run_sudo_with_retries(cmd, desc, max_retries=3, timeout=300):
for attempt in range(max_retries):
try:
logger.info(f"Running sudo command (attempt {attempt+1}/{max_retries}): {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=timeout)
if result.returncode == 0:
return True
else:
logger.error(f"sudo {desc} failed. Error: {result.stderr.strip()}")
print(f"Error: Failed to {desc}. Check logs.")
return False
except subprocess.TimeoutExpired:
logger.error(f"sudo {desc} timed out (attempt {attempt+1}/{max_retries}).")
print(f"\nSudo prompt timed out after {timeout} seconds. Please try again.")
# Flush input if possible, then retry
print(f"Failed to {desc} after {max_retries} attempts. Aborting.")
return False
# Run chown with retries
chown_command = ['sudo', 'chown', '-R', f'{user_name}:{group_name}', str(path)]
if not run_sudo_with_retries(chown_command, "change ownership"):
return False
print()
# Run chmod with retries
chmod_command = ['sudo', 'chmod', '-R', '755', str(path)]
if not run_sudo_with_retries(chmod_command, "set permissions"):
return False
print()
logger.info("Permissions set successfully.")
return True
@staticmethod
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
"""
DEPRECATED: Use verify_ownership_and_permissions() instead.
This method is kept for backwards compatibility but no longer executes sudo.
"""
logger.warning("set_ownership_and_permissions_sudo() is deprecated - use verify_ownership_and_permissions()")
success, error_msg = FileSystemHandler.verify_ownership_and_permissions(path)
if not success:
logger.error(error_msg)
print(error_msg)
return success
def download_file(self, url: str, destination_path: Path, overwrite: bool = False, quiet: bool = False) -> bool:
"""Downloads a file from a URL to a destination path."""

View File

@@ -14,7 +14,7 @@ import shutil
class LoggingHandler:
"""
Central logging handler for Jackify.
- Uses ~/Jackify/logs/ as the log directory.
- Uses configured Jackify data directory for logs (default: ~/Jackify/logs/).
- Supports per-function log files (e.g., jackify-install-wabbajack.log).
- Handles log rotation and log directory creation.
Usage:
@@ -186,5 +186,5 @@ class LoggingHandler:
return stats
def get_general_logger(self):
"""Get the general CLI logger (~/Jackify/logs/jackify-cli.log)."""
"""Get the general CLI logger ({jackify_data_dir}/logs/jackify-cli.log)."""
return self.setup_logger('jackify_cli', is_general=True)

View File

@@ -806,17 +806,18 @@ class ModlistHandler:
self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)")
self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done")
# Step 5: Ensure permissions of Modlist directory
# Step 5: Verify ownership of Modlist directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Setting ownership and permissions for modlist directory")
self.logger.info("Step 5: Setting ownership and permissions for modlist directory...")
status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership")
self.logger.info("Step 5: Verifying ownership of modlist directory...")
# Convert modlist_dir string to Path object for the method
modlist_path_obj = Path(self.modlist_dir)
if not self.filesystem_handler.set_ownership_and_permissions_sudo(modlist_path_obj):
self.logger.error("Failed to set ownership/permissions for modlist directory. Configuration aborted.")
print("Error: Failed to set permissions for the modlist directory.")
success, error_msg = self.filesystem_handler.verify_ownership_and_permissions(modlist_path_obj)
if not success:
self.logger.error("Ownership verification failed for modlist directory. Configuration aborted.")
print(f"\n{COLOR_ERROR}{error_msg}{COLOR_RESET}")
return False # Abort on failure
self.logger.info("Step 5: Setting ownership and permissions... Done")
self.logger.info("Step 5: Ownership verification... Done")
# Step 6: Backup ModOrganizer.ini
if status_callback:

View File

@@ -845,6 +845,10 @@ class ProgressStateManager:
self._wabbajack_entry_name = None
self._synthetic_flag = "_synthetic_wabbajack"
self._previous_phase = None # Track phase changes to reset stale data
# Track total download size from all files seen during download phase
self._download_files_seen = {} # filename -> (total_size, max_current_size)
self._download_total_bytes = 0 # Running total of all file sizes seen
self._download_processed_bytes = 0 # Running total of bytes processed
def process_line(self, line: str) -> bool:
"""
@@ -869,6 +873,12 @@ class ProgressStateManager:
# Phase is changing - selectively reset stale data from previous phase
previous_phase = self.state.phase
# Reset download tracking when leaving download phase
if previous_phase == InstallationPhase.DOWNLOAD:
self._download_files_seen = {}
self._download_total_bytes = 0
self._download_processed_bytes = 0
# Only reset data sizes when transitioning FROM VALIDATE phase
# Validation phase data sizes are from .wabbajack file and shouldn't persist
if previous_phase == InstallationPhase.VALIDATE and not parsed.data_info:
@@ -970,6 +980,39 @@ class ProgressStateManager:
if parsed.file_progress.operation == OperationType.DOWNLOAD:
self._remove_all_wabbajack_entries()
self._has_real_wabbajack = True # Prevent re-adding
# Track download totals from all files seen during download phase
# This allows us to calculate overall remaining/ETA even when engine doesn't report data_total
from jackify.shared.progress_models import OperationType
if self.state.phase == InstallationPhase.DOWNLOAD and parsed.file_progress.operation == OperationType.DOWNLOAD:
filename = parsed.file_progress.filename
total_size = parsed.file_progress.total_size or 0
current_size = parsed.file_progress.current_size or 0
# Track this file's max size and current progress
if filename not in self._download_files_seen:
# New file - add its total size to our running total
if total_size > 0:
self._download_total_bytes += total_size
self._download_files_seen[filename] = (total_size, current_size)
self._download_processed_bytes += current_size
else:
# Existing file - update current size and track max
old_total, old_current = self._download_files_seen[filename]
# If total_size increased (file size discovered), update our total
if total_size > old_total:
self._download_total_bytes += (total_size - old_total)
# Update processed bytes (only count increases)
if current_size > old_current:
self._download_processed_bytes += (current_size - old_current)
self._download_files_seen[filename] = (max(old_total, total_size), current_size)
# If engine didn't provide data_total, use our aggregated total
if self.state.data_total == 0 and self._download_total_bytes > 0:
self.state.data_total = self._download_total_bytes
self.state.data_processed = self._download_processed_bytes
updated = True
self._augment_file_metrics(parsed.file_progress)
# Don't add files that are already at 100% unless they're being updated
# This prevents re-adding completed files
@@ -1023,6 +1066,22 @@ class ProgressStateManager:
parsed.completed_filename = None
if parsed.completed_filename:
# Track completed files in download totals
if self.state.phase == InstallationPhase.DOWNLOAD:
filename = parsed.completed_filename
# If we were tracking this file, mark it as complete (100% of total)
if filename in self._download_files_seen:
old_total, old_current = self._download_files_seen[filename]
# Ensure processed bytes equals total for completed file
if old_current < old_total:
self._download_processed_bytes += (old_total - old_current)
self._download_files_seen[filename] = (old_total, old_total)
# Update state if needed
if self.state.data_total == 0 and self._download_total_bytes > 0:
self.state.data_total = self._download_total_bytes
self.state.data_processed = self._download_processed_bytes
updated = True
# Try to find existing file in the list
found_existing = False
for file_prog in self.state.active_files:

View File

@@ -453,13 +453,56 @@ class ProtontricksHandler:
return True
logger.info("Setting Protontricks permissions...")
# Bundled-runtime fix: Use cleaned environment
env = self._get_clean_subprocess_env()
permissions_set = []
permissions_failed = []
try:
# Bundled-runtime fix: Use cleaned environment
env = self._get_clean_subprocess_env()
# 1. Set permission for modlist directory (required for wine component installation)
logger.debug(f"Setting permission for modlist directory: {modlist_dir}")
try:
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
f"--filesystem={modlist_dir}"], check=True, env=env, capture_output=True)
permissions_set.append(f"modlist directory: {modlist_dir}")
except subprocess.CalledProcessError as e:
permissions_failed.append(f"modlist directory: {modlist_dir} ({e})")
logger.warning(f"Failed to set permission for modlist directory: {e}")
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
f"--filesystem={modlist_dir}"], check=True, env=env)
# 2. Set permission for main Steam directory (required for accessing compatdata, config, etc.)
steam_dir = self._get_steam_dir_from_libraryfolders()
if steam_dir and steam_dir.exists():
logger.info(f"Setting permission for Steam directory: {steam_dir}")
logger.debug("This allows protontricks to access Steam compatdata, config, and steamapps directories")
try:
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
f"--filesystem={steam_dir}"], check=True, env=env, capture_output=True)
permissions_set.append(f"Steam directory: {steam_dir}")
except subprocess.CalledProcessError as e:
permissions_failed.append(f"Steam directory: {steam_dir} ({e})")
logger.warning(f"Failed to set permission for Steam directory: {e}")
else:
logger.warning("Could not determine Steam directory - protontricks may not have access to Steam directories")
# 3. Set permissions for all additional Steam library folders (compatdata can be in any library)
from ..handlers.path_handler import PathHandler
all_library_paths = PathHandler.get_all_steam_library_paths()
for lib_path in all_library_paths:
# Skip if this is the main Steam directory (already set above)
if steam_dir and lib_path.resolve() == steam_dir.resolve():
continue
if lib_path.exists():
logger.debug(f"Setting permission for Steam library folder: {lib_path}")
try:
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
f"--filesystem={lib_path}"], check=True, env=env, capture_output=True)
permissions_set.append(f"Steam library: {lib_path}")
except subprocess.CalledProcessError as e:
permissions_failed.append(f"Steam library: {lib_path} ({e})")
logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}")
# 4. Set SD card permissions (Steam Deck only)
if steamdeck:
logger.warn("Checking for SDCard and setting permissions appropriately...")
# Find sdcard path
@@ -468,15 +511,40 @@ class ProtontricksHandler:
if "/run/media" in line:
sdcard_path = line.split()[-1]
logger.debug(f"SDCard path: {sdcard_path}")
subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}",
"com.github.Matoking.protontricks"], check=True, env=env)
try:
subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}",
"com.github.Matoking.protontricks"], check=True, env=env, capture_output=True)
permissions_set.append(f"SD card: {sdcard_path}")
except subprocess.CalledProcessError as e:
permissions_failed.append(f"SD card: {sdcard_path} ({e})")
logger.warning(f"Failed to set permission for SD card {sdcard_path}: {e}")
# Add standard Steam Deck SD card path as fallback
subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1",
"com.github.Matoking.protontricks"], check=True, env=env)
logger.debug("Permissions set successfully")
return True
try:
subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1",
"com.github.Matoking.protontricks"], check=True, env=env, capture_output=True)
permissions_set.append("SD card: /run/media/mmcblk0p1")
except subprocess.CalledProcessError as e:
# This is expected to fail if the path doesn't exist, so only log at debug level
logger.debug(f"Could not set permission for fallback SD card path (may not exist): {e}")
# Report results
if permissions_set:
logger.info(f"Successfully set {len(permissions_set)} permission(s) for protontricks")
logger.debug(f"Permissions set: {', '.join(permissions_set)}")
if permissions_failed:
logger.warning(f"Failed to set {len(permissions_failed)} permission(s)")
logger.debug(f"Failed permissions: {', '.join(permissions_failed)}")
# Return True if at least modlist directory permission was set (critical)
if any("modlist directory" in p for p in permissions_set):
logger.info("Protontricks permissions configured (at least modlist directory access granted)")
return True
else:
logger.error("Failed to set critical modlist directory permission")
return False
except Exception as e:
logger.error(f"Failed to set Protontricks permissions: {e}")
logger.error(f"Unexpected error while setting Protontricks permissions: {e}")
return False
def create_protontricks_alias(self):
@@ -903,6 +971,9 @@ class ProtontricksHandler:
Install the specified Wine components into the given prefix using protontricks.
If specific_components is None, use the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022).
"""
self.logger.info("=" * 80)
self.logger.info("USING PROTONTRICKS")
self.logger.info("=" * 80)
env = self._get_clean_subprocess_env()
env["WINEDEBUG"] = "-all"

View File

@@ -272,47 +272,45 @@ class WineUtils:
@staticmethod
def chown_chmod_modlist_dir(modlist_dir):
"""
Change ownership and permissions of modlist directory
Returns True on success, False on failure
DEPRECATED: Use FileSystemHandler.verify_ownership_and_permissions() instead.
Verify and fix ownership/permissions for modlist directory.
Returns True if successful, False if sudo required.
"""
if WineUtils.all_owned_by_user(modlist_dir):
logger.info(f"All files in {modlist_dir} are already owned by the current user. Skipping sudo chown/chmod.")
return True
logger.warn("Changing Ownership and Permissions of modlist directory (may require sudo password)")
try:
user = subprocess.run("whoami", shell=True, capture_output=True, text=True).stdout.strip()
group = subprocess.run("id -gn", shell=True, capture_output=True, text=True).stdout.strip()
logger.debug(f"User is {user} and Group is {group}")
# Change ownership
result1 = subprocess.run(
f"sudo chown -R {user}:{group} \"{modlist_dir}\"",
shell=True,
capture_output=True,
text=True
)
# Change permissions
result2 = subprocess.run(
f"sudo chmod -R 755 \"{modlist_dir}\"",
shell=True,
capture_output=True,
text=True
)
if result1.returncode != 0 or result2.returncode != 0:
logger.error("Failed to change ownership/permissions")
logger.error(f"chown output: {result1.stderr}")
logger.error(f"chmod output: {result2.stderr}")
if not WineUtils.all_owned_by_user(modlist_dir):
# Files not owned by us - need sudo to fix
logger.error(f"Ownership issue detected: Some files in {modlist_dir} are not owned by the current user")
try:
user = subprocess.run("whoami", shell=True, capture_output=True, text=True).stdout.strip()
group = subprocess.run("id -gn", shell=True, capture_output=True, text=True).stdout.strip()
logger.error("To fix ownership issues, open a terminal and run:")
logger.error(f" sudo chown -R {user}:{group} \"{modlist_dir}\"")
logger.error(f" sudo chmod -R 755 \"{modlist_dir}\"")
logger.error("After running these commands, retry the operation.")
return False
except Exception as e:
logger.error(f"Error checking ownership: {e}")
return False
# Files are owned by us - try to fix permissions ourselves
logger.info(f"Files in {modlist_dir} are owned by current user, verifying permissions...")
try:
result = subprocess.run(
['chmod', '-R', '755', modlist_dir],
capture_output=True,
text=True,
check=False
)
if result.returncode == 0:
logger.info(f"Permissions set successfully for {modlist_dir}")
else:
logger.warning(f"chmod returned non-zero but continuing: {result.stderr}")
return True
except Exception as e:
logger.error(f"Error changing ownership and permissions: {e}")
return False
logger.warning(f"Error running chmod: {e}, continuing anyway")
return True
@staticmethod
def create_dxvk_file(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full):

View File

@@ -266,13 +266,40 @@ class WinetricksHandler:
self.logger.error(f"Cannot run winetricks: Failed to get Proton wine binary: {e}")
return False
# CRITICAL: NEVER add bundled downloaders to PATH - they segfault on some systems
# Let winetricks auto-detect system downloaders (aria2c > wget > curl > fetch)
# Winetricks will automatically fall back if preferred tool isn't available
# We verify at least one exists before proceeding
# Quick check: does system have at least one downloader?
has_downloader = False
for tool in ['aria2c', 'curl', 'wget']:
try:
result = subprocess.run(['which', tool], capture_output=True, timeout=2, env=os.environ.copy())
if result.returncode == 0:
has_downloader = True
self.logger.info(f"System has {tool} available - winetricks will auto-select best option")
break
except Exception:
pass
if not has_downloader:
self._handle_missing_downloader_error()
return False
# Don't set WINETRICKS_DOWNLOADER - let winetricks auto-detect and fall back
# This ensures it uses the best available tool and handles fallbacks automatically
# Set up bundled tools directory for winetricks
# Get tools directory from any bundled tool (winetricks, cabextract, etc.)
# NEVER add bundled downloaders to PATH - they segfault on some systems
# Only bundle non-downloader tools: cabextract, unzip, 7z, xz, sha256sum
tools_dir = None
bundled_tools = []
# Check for bundled tools and collect their directory
tool_names = ['cabextract', 'wget', 'unzip', '7z', 'xz', 'sha256sum']
# Downloaders (aria2c, wget, curl) are NEVER bundled - always use system tools
tool_names = ['cabextract', 'unzip', '7z', 'xz', 'sha256sum']
for tool_name in tool_names:
bundled_tool = self._get_bundled_tool(tool_name, fallback_to_system=False)
if bundled_tool:
@@ -280,18 +307,30 @@ class WinetricksHandler:
if tools_dir is None:
tools_dir = os.path.dirname(bundled_tool)
# Prepend tools directory to PATH if we have any bundled tools
# Add bundled tools to PATH (system PATH first, so system downloaders are found first)
# NEVER add bundled downloaders - only archive/utility tools
if tools_dir:
env['PATH'] = f"{tools_dir}:{env.get('PATH', '')}"
self.logger.info(f"Using bundled tools directory: {tools_dir}")
self.logger.info(f"Bundled tools available: {', '.join(bundled_tools)}")
# System PATH first, then bundled tools (so system downloaders are always found first)
env['PATH'] = f"{env.get('PATH', '')}:{tools_dir}"
bundling_msg = f"Using bundled tools directory (after system PATH): {tools_dir}"
self.logger.info(bundling_msg)
if status_callback:
status_callback(bundling_msg)
tools_msg = f"Bundled tools available: {', '.join(bundled_tools)}"
self.logger.info(tools_msg)
if status_callback:
status_callback(tools_msg)
else:
self.logger.debug("No bundled tools found, relying on system PATH")
# CRITICAL: Check for winetricks dependencies BEFORE attempting installation
# This helps diagnose failures on systems where dependencies are missing
self.logger.info("=== Checking winetricks dependencies ===")
deps_check_msg = "=== Checking winetricks dependencies ==="
self.logger.info(deps_check_msg)
if status_callback:
status_callback(deps_check_msg)
missing_deps = []
bundled_tools_list = ['aria2c', 'wget', 'unzip', '7z', 'xz', 'sha256sum', 'cabextract']
dependency_checks = {
'wget': 'wget',
'curl': 'curl',
@@ -308,23 +347,30 @@ class WinetricksHandler:
if isinstance(commands, str):
commands = [commands]
# First check for bundled version
bundled_tool = None
for cmd in commands:
bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False)
if bundled_tool:
self.logger.info(f"{dep_name}: {bundled_tool} (bundled)")
found = True
break
# Check for bundled version only for tools we bundle
if dep_name in bundled_tools_list:
bundled_tool = None
for cmd in commands:
bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False)
if bundled_tool:
dep_msg = f"{dep_name}: {bundled_tool} (bundled)"
self.logger.info(dep_msg)
if status_callback:
status_callback(dep_msg)
found = True
break
# If not bundled, check system PATH
# Check system PATH if not found bundled
if not found:
for cmd in commands:
try:
result = subprocess.run(['which', cmd], capture_output=True, timeout=2)
if result.returncode == 0:
cmd_path = result.stdout.decode().strip()
self.logger.info(f"{dep_name}: {cmd_path} (system)")
dep_msg = f"{dep_name}: {cmd_path} (system)"
self.logger.info(dep_msg)
if status_callback:
status_callback(dep_msg)
found = True
break
except Exception:
@@ -332,12 +378,41 @@ class WinetricksHandler:
if not found:
missing_deps.append(dep_name)
self.logger.warning(f"{dep_name}: NOT FOUND (neither bundled nor system)")
if dep_name in bundled_tools_list:
self.logger.warning(f"{dep_name}: NOT FOUND (neither bundled nor system)")
else:
self.logger.warning(f"{dep_name}: NOT FOUND (system only - not bundled)")
if missing_deps:
self.logger.warning(f"Missing winetricks dependencies: {', '.join(missing_deps)}")
self.logger.warning("Winetricks may fail if these are required for component installation")
self.logger.warning("Critical dependencies: wget/curl/aria2c (download), unzip/7z (extract)")
# Separate critical vs optional dependencies
download_deps = [d for d in missing_deps if d in ['wget', 'curl', 'aria2c']]
critical_deps = [d for d in missing_deps if d not in ['aria2c']]
optional_deps = [d for d in missing_deps if d in ['aria2c']]
if critical_deps:
self.logger.warning(f"Missing critical winetricks dependencies: {', '.join(critical_deps)}")
self.logger.warning("Winetricks may fail if these are required for component installation")
if optional_deps:
self.logger.info(f"Optional dependencies not found (will use alternatives): {', '.join(optional_deps)}")
self.logger.info("aria2c is optional - winetricks will use wget/curl if available")
# Special warning if ALL downloaders are missing
all_downloaders = {'wget', 'curl', 'aria2c'}
missing_downloaders = set(download_deps)
if missing_downloaders == all_downloaders:
self.logger.error("=" * 80)
self.logger.error("CRITICAL: No download tools found (wget, curl, or aria2c)")
self.logger.error("Winetricks requires at least ONE download tool to install components")
self.logger.error("")
self.logger.error("SOLUTION: Install one of the following:")
self.logger.error(" - aria2c (preferred): sudo apt install aria2 # or equivalent for your distro")
self.logger.error(" - curl: sudo apt install curl # or equivalent for your distro")
self.logger.error(" - wget: sudo apt install wget # or equivalent for your distro")
self.logger.error("=" * 80)
else:
self.logger.warning("Critical dependencies: wget/curl (download), unzip/7z (extract)")
self.logger.info("Optional dependencies: aria2c (preferred but not required)")
else:
self.logger.info("All winetricks dependencies found")
self.logger.info("========================================")
@@ -385,11 +460,17 @@ class WinetricksHandler:
# Choose installation method based on user preference
if method == 'system_protontricks':
self.logger.info("=" * 80)
self.logger.info("USING PROTONTRICKS")
self.logger.info("=" * 80)
self.logger.info("Using system protontricks for all components")
return self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback)
# else: method == 'winetricks' (default behavior continues below)
# Install all components together with winetricks (faster)
self.logger.info("=" * 80)
self.logger.info("USING WINETRICKS")
self.logger.info("=" * 80)
max_attempts = 3
winetricks_failed = False
last_error_details = None
@@ -472,8 +553,7 @@ class WinetricksHandler:
'attempt': attempt
}
# CRITICAL: Always log full error details (not just in debug mode)
# This helps diagnose failures on systems we can't replicate
# Log full error details to help diagnose failures
self.logger.error("=" * 80)
self.logger.error(f"WINETRICKS FAILED (Attempt {attempt}/{max_attempts})")
self.logger.error(f"Return Code: {result.returncode}")
@@ -521,6 +601,10 @@ class WinetricksHandler:
self.logger.error(" - Component download may be corrupted")
self.logger.error(" - Network issue or upstream file change")
diagnostic_found = True
elif ("please install" in stderr_lower or "please install" in stdout_lower) and ("wget" in stderr_lower or "aria2c" in stderr_lower or "curl" in stderr_lower or "wget" in stdout_lower or "aria2c" in stdout_lower or "curl" in stdout_lower):
# Winetricks explicitly says to install a downloader
self._handle_missing_downloader_error()
diagnostic_found = True
elif "curl" in stderr_lower or "wget" in stderr_lower or "aria2c" in stderr_lower:
self.logger.error("DIAGNOSTIC: Download tool (curl/wget/aria2c) issue")
self.logger.error(" - Network connectivity problem or missing download tool")
@@ -536,11 +620,6 @@ class WinetricksHandler:
self.logger.error(" - Required for extracting zip/7z archives")
self.logger.error(" - Check dependency check output above")
diagnostic_found = True
elif "please install" in stderr_lower:
self.logger.error("DIAGNOSTIC: Winetricks explicitly requesting dependency installation")
self.logger.error(" - Winetricks detected missing required tool")
self.logger.error(" - Check dependency check output above")
diagnostic_found = True
if not diagnostic_found:
self.logger.error("DIAGNOSTIC: Unknown winetricks failure pattern")
@@ -611,6 +690,9 @@ class WinetricksHandler:
self.logger.warning("AUTOMATIC FALLBACK: Winetricks failed, attempting protontricks fallback...")
self.logger.warning(f"Last winetricks error: {last_error_details}")
self.logger.warning("=" * 80)
self.logger.info("=" * 80)
self.logger.info("USING PROTONTRICKS")
self.logger.info("=" * 80)
# Attempt fallback to protontricks
fallback_success = self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback)
@@ -631,6 +713,53 @@ class WinetricksHandler:
return False
def _handle_missing_downloader_error(self):
"""Handle winetricks error indicating missing downloader - provide platform-specific instructions"""
from ..services.platform_detection_service import PlatformDetectionService
platform = PlatformDetectionService.get_instance()
is_steamos = platform.is_steamdeck
self.logger.error("=" * 80)
self.logger.error("CRITICAL: Winetricks cannot find a downloader (curl, wget, or aria2c)")
self.logger.error("")
if is_steamos:
self.logger.error("STEAMOS/STEAM DECK DETECTED")
self.logger.error("")
self.logger.error("SteamOS has a read-only filesystem. To install packages:")
self.logger.error("")
self.logger.error("1. Disable read-only mode (required for package installation):")
self.logger.error(" sudo steamos-readonly disable")
self.logger.error("")
self.logger.error("2. Install curl (recommended - most reliable):")
self.logger.error(" sudo pacman -S curl")
self.logger.error("")
self.logger.error("3. (Optional) Re-enable read-only mode after installation:")
self.logger.error(" sudo steamos-readonly enable")
self.logger.error("")
self.logger.error("Note: curl is usually pre-installed on SteamOS. If missing,")
self.logger.error(" the above steps will install it.")
else:
self.logger.error("SOLUTION: Install one of the following downloaders:")
self.logger.error("")
self.logger.error(" For Debian/Ubuntu/PopOS:")
self.logger.error(" sudo apt install curl # or: sudo apt install wget")
self.logger.error("")
self.logger.error(" For Fedora/RHEL/CentOS:")
self.logger.error(" sudo dnf install curl # or: sudo dnf install wget")
self.logger.error("")
self.logger.error(" For Arch/Manjaro:")
self.logger.error(" sudo pacman -S curl # or: sudo pacman -S wget")
self.logger.error("")
self.logger.error(" For openSUSE:")
self.logger.error(" sudo zypper install curl # or: sudo zypper install wget")
self.logger.error("")
self.logger.error("Note: Most Linux distributions include curl by default.")
self.logger.error(" If curl is missing, install it using your package manager.")
self.logger.error("=" * 80)
def _reorder_components_for_installation(self, components: list) -> list:
"""
Reorder components for proper installation sequence if needed.

View File

@@ -3057,19 +3057,20 @@ echo Prefix creation complete.
# SD card paths use D: drive
# Strip SD card prefix using the same method as other handlers
relative_sd_path_str = PathHandler._strip_sdcard_path_prefix(linux_path)
wine_path = relative_sd_path_str.replace('/', '\\')
wine_path = relative_sd_path_str.replace('/', '\\\\')
wine_drive = "D:"
logger.debug(f"SD card path detected: {new_path} -> D:\\{wine_path}")
else:
# Regular paths use Z: drive with full path
wine_path = new_path.strip('/').replace('/', '\\')
wine_path = new_path.strip('/').replace('/', '\\\\')
wine_drive = "Z:"
logger.debug(f"Regular path: {new_path} -> Z:\\{wine_path}")
# Update existing path if found
for i, line in enumerate(lines):
stripped_line = line.strip()
if stripped_line == section_name:
# Case-insensitive comparison for section name (Wine registry is case-insensitive)
if stripped_line.split(']')[0].lower() + ']' == section_name.lower() if ']' in stripped_line else stripped_line.lower() == section_name.lower():
in_target_section = True
elif stripped_line.startswith('[') and in_target_section:
in_target_section = False
@@ -3265,7 +3266,7 @@ echo Prefix creation complete.
"22380": { # Fallout New Vegas AppID
"name": "Fallout New Vegas",
"common_names": ["Fallout New Vegas", "FalloutNV"],
"registry_section": "[Software\\\\WOW6432Node\\\\bethesda softworks\\\\falloutnv]",
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]",
"path_key": "Installed Path"
},
"976620": { # Enderal Special Edition AppID

View File

@@ -628,7 +628,7 @@ class ModlistService:
except Exception as e:
logger.error(f"Failed to configure modlist {context.name}: {e}")
if completion_callback:
completion_callback(False, f"Configuration failed: {e}", context.name)
completion_callback(False, f"Configuration failed: {e}", context.name, False)
# Clean up GUI log handler on exception
if gui_log_handler:
@@ -695,11 +695,11 @@ class ModlistService:
if success:
logger.info("Modlist configuration completed successfully")
if completion_callback:
completion_callback(True, "Configuration completed successfully", context.name)
completion_callback(True, "Configuration completed successfully", context.name, False)
else:
logger.warning("Modlist configuration had issues")
if completion_callback:
completion_callback(False, "Configuration failed", context.name)
completion_callback(False, "Configuration failed", context.name, False)
return success

View File

@@ -41,9 +41,9 @@ class PlatformDetectionService:
if os.path.exists('/etc/os-release'):
with open('/etc/os-release', 'r') as f:
content = f.read().lower()
if 'steamdeck' in content:
if 'steamdeck' in content or 'steamos' in content:
self._is_steamdeck = True
logger.info("Steam Deck platform detected")
logger.info("Steam Deck/SteamOS platform detected")
else:
logger.debug("Non-Steam Deck Linux platform detected")
else: