mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 11:37:01 +01:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9000b1e080 | ||
|
|
02f3d71a82 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,8 +1,30 @@
|
|||||||
# Jackify Changelog
|
# Jackify Changelog
|
||||||
|
|
||||||
|
## v0.2.1.1 - Bug Fixes and Improvements
|
||||||
|
**Release Date:** 2026-01-15
|
||||||
|
|
||||||
|
### Critical Bug Fixes
|
||||||
|
- **AppImage Crash on Steam Deck**: Fixed `NameError: name 'Tuple' is not defined` that prevented AppImage from launching on Steam Deck. Added missing `Tuple` import to `progress_models.py`
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Menu Routing**: Fixed "Configure Existing Modlist (In Steam)" opening wrong section (was routing to Wabbajack Installer instead of Configure Existing screen)
|
||||||
|
- **TTW Install Dialogue**: Fixed incorrect account reference (changed "mod.db" to "ModPub" to match actual download source)
|
||||||
|
- **Duplicate Method**: Removed duplicate `_handle_missing_downloader_error` method in winetricks handler
|
||||||
|
- **Issue #142**: Removed sudo execution from modlist configuration - now auto-fixes permissions when possible, provides manual instructions only when sudo required
|
||||||
|
- **Issue #133**: Updated VDF library to 4.0 for improved Steam file format compatibility (protontricks 1.13.1+ support)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Wine Component Error Handling**: Enhanced error messages for missing downloaders with platform-specific installation instructions (SteamOS/Steam Deck vs other distros)
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- **VDF Library**: Updated from PyPI vdf 3.4 to actively maintained solsticegamestudios/vdf 4.0 (used by Gentoo)
|
||||||
|
- **Winetricks**: Removed bundled downloaders that caused segfaults on some systems - now uses system-provided downloaders (aria2c/wget/curl)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.2.1 - Wabbajack Installer and ENB Support
|
## v0.2.1 - Wabbajack Installer and ENB Support
|
||||||
**Release Date:** 2025-01-12
|
**Release Date:** 2025-01-12
|
||||||
|
Y
|
||||||
### Major Features
|
### Major Features
|
||||||
- **Automated Wabbajack Installation**: While I work on Non-Premium support, there is still a call for Wabbajack via Proton. The existing legacy bash script has been proving troublesome for some users, so I've added this as a new feature within Jackify. My aim is still to not need this in future, once Jackify can cover Non-Premium accounts.
|
- **Automated Wabbajack Installation**: While I work on Non-Premium support, there is still a call for Wabbajack via Proton. The existing legacy bash script has been proving troublesome for some users, so I've added this as a new feature within Jackify. My aim is still to not need this in future, once Jackify can cover Non-Premium accounts.
|
||||||
- **ENB Detection and Configuration**: Automatic detection and configuration of `enblocal.ini` with `LinuxVersion=true` for all supported games
|
- **ENB Detection and Configuration**: Automatic detection and configuration of `enblocal.ini` with `LinuxVersion=true` for all supported games
|
||||||
|
|||||||
@@ -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.2.1"
|
__version__ = "0.2.1.1"
|
||||||
|
|||||||
@@ -721,59 +721,75 @@ class FileSystemHandler:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
|
def verify_ownership_and_permissions(path: Path) -> tuple[bool, str]:
|
||||||
"""Change ownership and permissions using sudo (robust, with timeout and re-prompt)."""
|
"""
|
||||||
|
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():
|
if not path.exists():
|
||||||
logger.error(f"Path does not exist: {path}")
|
logger.error(f"Path does not exist: {path}")
|
||||||
return False
|
return False, f"Path does not exist: {path}"
|
||||||
# Check if all files/dirs are already owned by the user
|
|
||||||
if FileSystemHandler.all_owned_by_user(path):
|
# Check if all files/dirs are owned by the user
|
||||||
logger.info(f"All files in {path} are already owned by the current user. Skipping sudo chown/chmod.")
|
if not FileSystemHandler.all_owned_by_user(path):
|
||||||
return True
|
# 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:
|
try:
|
||||||
user_name = pwd.getpwuid(os.geteuid()).pw_name
|
result = subprocess.run(
|
||||||
group_name = grp.getgrgid(os.geteuid()).gr_name
|
['chmod', '-R', '755', str(path)],
|
||||||
except KeyError:
|
capture_output=True,
|
||||||
logger.error("Could not determine current user or group name.")
|
text=True,
|
||||||
return False
|
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."
|
@staticmethod
|
||||||
logger.info(log_msg)
|
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
|
||||||
if status_callback:
|
"""
|
||||||
status_callback(f"Setting ownership/permissions for {os.path.basename(str(path))}...")
|
DEPRECATED: Use verify_ownership_and_permissions() instead.
|
||||||
else:
|
This method is kept for backwards compatibility but no longer executes sudo.
|
||||||
print(f'\n{COLOR_PROMPT}Adjusting permissions for {path} (may require sudo password)...{COLOR_RESET}')
|
"""
|
||||||
|
logger.warning("set_ownership_and_permissions_sudo() is deprecated - use verify_ownership_and_permissions()")
|
||||||
def run_sudo_with_retries(cmd, desc, max_retries=3, timeout=300):
|
success, error_msg = FileSystemHandler.verify_ownership_and_permissions(path)
|
||||||
for attempt in range(max_retries):
|
if not success:
|
||||||
try:
|
logger.error(error_msg)
|
||||||
logger.info(f"Running sudo command (attempt {attempt+1}/{max_retries}): {' '.join(cmd)}")
|
print(error_msg)
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=timeout)
|
return success
|
||||||
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
|
|
||||||
|
|
||||||
def download_file(self, url: str, destination_path: Path, overwrite: bool = False, quiet: bool = False) -> bool:
|
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."""
|
"""Downloads a file from a URL to a destination path."""
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import shutil
|
|||||||
class LoggingHandler:
|
class LoggingHandler:
|
||||||
"""
|
"""
|
||||||
Central logging handler for Jackify.
|
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).
|
- Supports per-function log files (e.g., jackify-install-wabbajack.log).
|
||||||
- Handles log rotation and log directory creation.
|
- Handles log rotation and log directory creation.
|
||||||
Usage:
|
Usage:
|
||||||
@@ -186,5 +186,5 @@ class LoggingHandler:
|
|||||||
return stats
|
return stats
|
||||||
|
|
||||||
def get_general_logger(self):
|
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)
|
return self.setup_logger('jackify_cli', is_general=True)
|
||||||
@@ -806,17 +806,18 @@ class ModlistHandler:
|
|||||||
self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)")
|
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")
|
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:
|
if status_callback:
|
||||||
status_callback(f"{self._get_progress_timestamp()} Setting ownership and permissions for modlist directory")
|
status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership")
|
||||||
self.logger.info("Step 5: Setting ownership and permissions for modlist directory...")
|
self.logger.info("Step 5: Verifying ownership of modlist directory...")
|
||||||
# Convert modlist_dir string to Path object for the method
|
# Convert modlist_dir string to Path object for the method
|
||||||
modlist_path_obj = Path(self.modlist_dir)
|
modlist_path_obj = Path(self.modlist_dir)
|
||||||
if not self.filesystem_handler.set_ownership_and_permissions_sudo(modlist_path_obj):
|
success, error_msg = self.filesystem_handler.verify_ownership_and_permissions(modlist_path_obj)
|
||||||
self.logger.error("Failed to set ownership/permissions for modlist directory. Configuration aborted.")
|
if not success:
|
||||||
print("Error: Failed to set permissions for the modlist directory.")
|
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
|
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
|
# Step 6: Backup ModOrganizer.ini
|
||||||
if status_callback:
|
if status_callback:
|
||||||
|
|||||||
@@ -845,6 +845,10 @@ class ProgressStateManager:
|
|||||||
self._wabbajack_entry_name = None
|
self._wabbajack_entry_name = None
|
||||||
self._synthetic_flag = "_synthetic_wabbajack"
|
self._synthetic_flag = "_synthetic_wabbajack"
|
||||||
self._previous_phase = None # Track phase changes to reset stale data
|
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:
|
def process_line(self, line: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -869,6 +873,12 @@ class ProgressStateManager:
|
|||||||
# Phase is changing - selectively reset stale data from previous phase
|
# Phase is changing - selectively reset stale data from previous phase
|
||||||
previous_phase = self.state.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
|
# Only reset data sizes when transitioning FROM VALIDATE phase
|
||||||
# Validation phase data sizes are from .wabbajack file and shouldn't persist
|
# Validation phase data sizes are from .wabbajack file and shouldn't persist
|
||||||
if previous_phase == InstallationPhase.VALIDATE and not parsed.data_info:
|
if previous_phase == InstallationPhase.VALIDATE and not parsed.data_info:
|
||||||
@@ -970,6 +980,39 @@ class ProgressStateManager:
|
|||||||
if parsed.file_progress.operation == OperationType.DOWNLOAD:
|
if parsed.file_progress.operation == OperationType.DOWNLOAD:
|
||||||
self._remove_all_wabbajack_entries()
|
self._remove_all_wabbajack_entries()
|
||||||
self._has_real_wabbajack = True # Prevent re-adding
|
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)
|
self._augment_file_metrics(parsed.file_progress)
|
||||||
# Don't add files that are already at 100% unless they're being updated
|
# Don't add files that are already at 100% unless they're being updated
|
||||||
# This prevents re-adding completed files
|
# This prevents re-adding completed files
|
||||||
@@ -1023,6 +1066,22 @@ class ProgressStateManager:
|
|||||||
parsed.completed_filename = None
|
parsed.completed_filename = None
|
||||||
|
|
||||||
if parsed.completed_filename:
|
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
|
# Try to find existing file in the list
|
||||||
found_existing = False
|
found_existing = False
|
||||||
for file_prog in self.state.active_files:
|
for file_prog in self.state.active_files:
|
||||||
|
|||||||
@@ -453,13 +453,56 @@ class ProtontricksHandler:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
logger.info("Setting Protontricks permissions...")
|
logger.info("Setting Protontricks permissions...")
|
||||||
|
# Bundled-runtime fix: Use cleaned environment
|
||||||
|
env = self._get_clean_subprocess_env()
|
||||||
|
|
||||||
|
permissions_set = []
|
||||||
|
permissions_failed = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Bundled-runtime fix: Use cleaned environment
|
# 1. Set permission for modlist directory (required for wine component installation)
|
||||||
env = self._get_clean_subprocess_env()
|
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",
|
# 2. Set permission for main Steam directory (required for accessing compatdata, config, etc.)
|
||||||
f"--filesystem={modlist_dir}"], check=True, env=env)
|
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:
|
if steamdeck:
|
||||||
logger.warn("Checking for SDCard and setting permissions appropriately...")
|
logger.warn("Checking for SDCard and setting permissions appropriately...")
|
||||||
# Find sdcard path
|
# Find sdcard path
|
||||||
@@ -468,15 +511,40 @@ class ProtontricksHandler:
|
|||||||
if "/run/media" in line:
|
if "/run/media" in line:
|
||||||
sdcard_path = line.split()[-1]
|
sdcard_path = line.split()[-1]
|
||||||
logger.debug(f"SDCard path: {sdcard_path}")
|
logger.debug(f"SDCard path: {sdcard_path}")
|
||||||
subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}",
|
try:
|
||||||
"com.github.Matoking.protontricks"], check=True, env=env)
|
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
|
# Add standard Steam Deck SD card path as fallback
|
||||||
subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1",
|
try:
|
||||||
"com.github.Matoking.protontricks"], check=True, env=env)
|
subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1",
|
||||||
logger.debug("Permissions set successfully")
|
"com.github.Matoking.protontricks"], check=True, env=env, capture_output=True)
|
||||||
return 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:
|
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
|
return False
|
||||||
|
|
||||||
def create_protontricks_alias(self):
|
def create_protontricks_alias(self):
|
||||||
@@ -903,6 +971,9 @@ class ProtontricksHandler:
|
|||||||
Install the specified Wine components into the given prefix using protontricks.
|
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).
|
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 = self._get_clean_subprocess_env()
|
||||||
env["WINEDEBUG"] = "-all"
|
env["WINEDEBUG"] = "-all"
|
||||||
|
|
||||||
|
|||||||
@@ -272,47 +272,45 @@ class WineUtils:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def chown_chmod_modlist_dir(modlist_dir):
|
def chown_chmod_modlist_dir(modlist_dir):
|
||||||
"""
|
"""
|
||||||
Change ownership and permissions of modlist directory
|
DEPRECATED: Use FileSystemHandler.verify_ownership_and_permissions() instead.
|
||||||
Returns True on success, False on failure
|
Verify and fix ownership/permissions for modlist directory.
|
||||||
|
Returns True if successful, False if sudo required.
|
||||||
"""
|
"""
|
||||||
if WineUtils.all_owned_by_user(modlist_dir):
|
if not 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.")
|
# Files not owned by us - need sudo to fix
|
||||||
return True
|
logger.error(f"Ownership issue detected: Some files in {modlist_dir} are not owned by the current user")
|
||||||
logger.warn("Changing Ownership and Permissions of modlist directory (may require sudo password)")
|
|
||||||
|
try:
|
||||||
try:
|
user = subprocess.run("whoami", shell=True, capture_output=True, text=True).stdout.strip()
|
||||||
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()
|
||||||
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.debug(f"User is {user} and Group is {group}")
|
logger.error(f" sudo chown -R {user}:{group} \"{modlist_dir}\"")
|
||||||
|
logger.error(f" sudo chmod -R 755 \"{modlist_dir}\"")
|
||||||
# Change ownership
|
logger.error("After running these commands, retry the operation.")
|
||||||
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}")
|
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error changing ownership and permissions: {e}")
|
logger.warning(f"Error running chmod: {e}, continuing anyway")
|
||||||
return False
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_dxvk_file(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full):
|
def create_dxvk_file(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full):
|
||||||
|
|||||||
@@ -266,13 +266,40 @@ class WinetricksHandler:
|
|||||||
self.logger.error(f"Cannot run winetricks: Failed to get Proton wine binary: {e}")
|
self.logger.error(f"Cannot run winetricks: Failed to get Proton wine binary: {e}")
|
||||||
return False
|
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
|
# 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
|
tools_dir = None
|
||||||
bundled_tools = []
|
bundled_tools = []
|
||||||
|
|
||||||
# Check for bundled tools and collect their directory
|
# 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:
|
for tool_name in tool_names:
|
||||||
bundled_tool = self._get_bundled_tool(tool_name, fallback_to_system=False)
|
bundled_tool = self._get_bundled_tool(tool_name, fallback_to_system=False)
|
||||||
if bundled_tool:
|
if bundled_tool:
|
||||||
@@ -280,18 +307,30 @@ class WinetricksHandler:
|
|||||||
if tools_dir is None:
|
if tools_dir is None:
|
||||||
tools_dir = os.path.dirname(bundled_tool)
|
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:
|
if tools_dir:
|
||||||
env['PATH'] = f"{tools_dir}:{env.get('PATH', '')}"
|
# System PATH first, then bundled tools (so system downloaders are always found first)
|
||||||
self.logger.info(f"Using bundled tools directory: {tools_dir}")
|
env['PATH'] = f"{env.get('PATH', '')}:{tools_dir}"
|
||||||
self.logger.info(f"Bundled tools available: {', '.join(bundled_tools)}")
|
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:
|
else:
|
||||||
self.logger.debug("No bundled tools found, relying on system PATH")
|
self.logger.debug("No bundled tools found, relying on system PATH")
|
||||||
|
|
||||||
# CRITICAL: Check for winetricks dependencies BEFORE attempting installation
|
# CRITICAL: Check for winetricks dependencies BEFORE attempting installation
|
||||||
# This helps diagnose failures on systems where dependencies are missing
|
# 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 = []
|
missing_deps = []
|
||||||
|
bundled_tools_list = ['aria2c', 'wget', 'unzip', '7z', 'xz', 'sha256sum', 'cabextract']
|
||||||
dependency_checks = {
|
dependency_checks = {
|
||||||
'wget': 'wget',
|
'wget': 'wget',
|
||||||
'curl': 'curl',
|
'curl': 'curl',
|
||||||
@@ -308,23 +347,30 @@ class WinetricksHandler:
|
|||||||
if isinstance(commands, str):
|
if isinstance(commands, str):
|
||||||
commands = [commands]
|
commands = [commands]
|
||||||
|
|
||||||
# First check for bundled version
|
# Check for bundled version only for tools we bundle
|
||||||
bundled_tool = None
|
if dep_name in bundled_tools_list:
|
||||||
for cmd in commands:
|
bundled_tool = None
|
||||||
bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False)
|
for cmd in commands:
|
||||||
if bundled_tool:
|
bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False)
|
||||||
self.logger.info(f" ✓ {dep_name}: {bundled_tool} (bundled)")
|
if bundled_tool:
|
||||||
found = True
|
dep_msg = f" ✓ {dep_name}: {bundled_tool} (bundled)"
|
||||||
break
|
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:
|
if not found:
|
||||||
for cmd in commands:
|
for cmd in commands:
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['which', cmd], capture_output=True, timeout=2)
|
result = subprocess.run(['which', cmd], capture_output=True, timeout=2)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
cmd_path = result.stdout.decode().strip()
|
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
|
found = True
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -332,12 +378,41 @@ class WinetricksHandler:
|
|||||||
|
|
||||||
if not found:
|
if not found:
|
||||||
missing_deps.append(dep_name)
|
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:
|
if missing_deps:
|
||||||
self.logger.warning(f"Missing winetricks dependencies: {', '.join(missing_deps)}")
|
# Separate critical vs optional dependencies
|
||||||
self.logger.warning("Winetricks may fail if these are required for component installation")
|
download_deps = [d for d in missing_deps if d in ['wget', 'curl', 'aria2c']]
|
||||||
self.logger.warning("Critical dependencies: wget/curl/aria2c (download), unzip/7z (extract)")
|
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:
|
else:
|
||||||
self.logger.info("All winetricks dependencies found")
|
self.logger.info("All winetricks dependencies found")
|
||||||
self.logger.info("========================================")
|
self.logger.info("========================================")
|
||||||
@@ -385,11 +460,17 @@ class WinetricksHandler:
|
|||||||
|
|
||||||
# Choose installation method based on user preference
|
# Choose installation method based on user preference
|
||||||
if method == 'system_protontricks':
|
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")
|
self.logger.info("Using system protontricks for all components")
|
||||||
return self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback)
|
return self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback)
|
||||||
# else: method == 'winetricks' (default behavior continues below)
|
# else: method == 'winetricks' (default behavior continues below)
|
||||||
|
|
||||||
# Install all components together with winetricks (faster)
|
# Install all components together with winetricks (faster)
|
||||||
|
self.logger.info("=" * 80)
|
||||||
|
self.logger.info("USING WINETRICKS")
|
||||||
|
self.logger.info("=" * 80)
|
||||||
max_attempts = 3
|
max_attempts = 3
|
||||||
winetricks_failed = False
|
winetricks_failed = False
|
||||||
last_error_details = None
|
last_error_details = None
|
||||||
@@ -472,8 +553,7 @@ class WinetricksHandler:
|
|||||||
'attempt': attempt
|
'attempt': attempt
|
||||||
}
|
}
|
||||||
|
|
||||||
# CRITICAL: Always log full error details (not just in debug mode)
|
# Log full error details to help diagnose failures
|
||||||
# This helps diagnose failures on systems we can't replicate
|
|
||||||
self.logger.error("=" * 80)
|
self.logger.error("=" * 80)
|
||||||
self.logger.error(f"WINETRICKS FAILED (Attempt {attempt}/{max_attempts})")
|
self.logger.error(f"WINETRICKS FAILED (Attempt {attempt}/{max_attempts})")
|
||||||
self.logger.error(f"Return Code: {result.returncode}")
|
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(" - Component download may be corrupted")
|
||||||
self.logger.error(" - Network issue or upstream file change")
|
self.logger.error(" - Network issue or upstream file change")
|
||||||
diagnostic_found = True
|
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:
|
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("DIAGNOSTIC: Download tool (curl/wget/aria2c) issue")
|
||||||
self.logger.error(" - Network connectivity problem or missing download tool")
|
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(" - Required for extracting zip/7z archives")
|
||||||
self.logger.error(" - Check dependency check output above")
|
self.logger.error(" - Check dependency check output above")
|
||||||
diagnostic_found = True
|
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:
|
if not diagnostic_found:
|
||||||
self.logger.error("DIAGNOSTIC: Unknown winetricks failure pattern")
|
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("AUTOMATIC FALLBACK: Winetricks failed, attempting protontricks fallback...")
|
||||||
self.logger.warning(f"Last winetricks error: {last_error_details}")
|
self.logger.warning(f"Last winetricks error: {last_error_details}")
|
||||||
self.logger.warning("=" * 80)
|
self.logger.warning("=" * 80)
|
||||||
|
self.logger.info("=" * 80)
|
||||||
|
self.logger.info("USING PROTONTRICKS")
|
||||||
|
self.logger.info("=" * 80)
|
||||||
|
|
||||||
# Attempt fallback to protontricks
|
# Attempt fallback to protontricks
|
||||||
fallback_success = self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback)
|
fallback_success = self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback)
|
||||||
@@ -631,6 +713,53 @@ class WinetricksHandler:
|
|||||||
|
|
||||||
return False
|
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:
|
def _reorder_components_for_installation(self, components: list) -> list:
|
||||||
"""
|
"""
|
||||||
Reorder components for proper installation sequence if needed.
|
Reorder components for proper installation sequence if needed.
|
||||||
|
|||||||
@@ -3057,19 +3057,20 @@ echo Prefix creation complete.
|
|||||||
# SD card paths use D: drive
|
# SD card paths use D: drive
|
||||||
# Strip SD card prefix using the same method as other handlers
|
# Strip SD card prefix using the same method as other handlers
|
||||||
relative_sd_path_str = PathHandler._strip_sdcard_path_prefix(linux_path)
|
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:"
|
wine_drive = "D:"
|
||||||
logger.debug(f"SD card path detected: {new_path} -> D:\\{wine_path}")
|
logger.debug(f"SD card path detected: {new_path} -> D:\\{wine_path}")
|
||||||
else:
|
else:
|
||||||
# Regular paths use Z: drive with full path
|
# Regular paths use Z: drive with full path
|
||||||
wine_path = new_path.strip('/').replace('/', '\\')
|
wine_path = new_path.strip('/').replace('/', '\\\\')
|
||||||
wine_drive = "Z:"
|
wine_drive = "Z:"
|
||||||
logger.debug(f"Regular path: {new_path} -> Z:\\{wine_path}")
|
logger.debug(f"Regular path: {new_path} -> Z:\\{wine_path}")
|
||||||
|
|
||||||
# Update existing path if found
|
# Update existing path if found
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
stripped_line = line.strip()
|
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
|
in_target_section = True
|
||||||
elif stripped_line.startswith('[') and in_target_section:
|
elif stripped_line.startswith('[') and in_target_section:
|
||||||
in_target_section = False
|
in_target_section = False
|
||||||
@@ -3265,7 +3266,7 @@ echo Prefix creation complete.
|
|||||||
"22380": { # Fallout New Vegas AppID
|
"22380": { # Fallout New Vegas AppID
|
||||||
"name": "Fallout New Vegas",
|
"name": "Fallout New Vegas",
|
||||||
"common_names": ["Fallout New Vegas", "FalloutNV"],
|
"common_names": ["Fallout New Vegas", "FalloutNV"],
|
||||||
"registry_section": "[Software\\\\WOW6432Node\\\\bethesda softworks\\\\falloutnv]",
|
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]",
|
||||||
"path_key": "Installed Path"
|
"path_key": "Installed Path"
|
||||||
},
|
},
|
||||||
"976620": { # Enderal Special Edition AppID
|
"976620": { # Enderal Special Edition AppID
|
||||||
|
|||||||
@@ -628,7 +628,7 @@ class ModlistService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to configure modlist {context.name}: {e}")
|
logger.error(f"Failed to configure modlist {context.name}: {e}")
|
||||||
if completion_callback:
|
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
|
# Clean up GUI log handler on exception
|
||||||
if gui_log_handler:
|
if gui_log_handler:
|
||||||
@@ -695,11 +695,11 @@ class ModlistService:
|
|||||||
if success:
|
if success:
|
||||||
logger.info("Modlist configuration completed successfully")
|
logger.info("Modlist configuration completed successfully")
|
||||||
if completion_callback:
|
if completion_callback:
|
||||||
completion_callback(True, "Configuration completed successfully", context.name)
|
completion_callback(True, "Configuration completed successfully", context.name, False)
|
||||||
else:
|
else:
|
||||||
logger.warning("Modlist configuration had issues")
|
logger.warning("Modlist configuration had issues")
|
||||||
if completion_callback:
|
if completion_callback:
|
||||||
completion_callback(False, "Configuration failed", context.name)
|
completion_callback(False, "Configuration failed", context.name, False)
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ class PlatformDetectionService:
|
|||||||
if os.path.exists('/etc/os-release'):
|
if os.path.exists('/etc/os-release'):
|
||||||
with open('/etc/os-release', 'r') as f:
|
with open('/etc/os-release', 'r') as f:
|
||||||
content = f.read().lower()
|
content = f.read().lower()
|
||||||
if 'steamdeck' in content:
|
if 'steamdeck' in content or 'steamos' in content:
|
||||||
self._is_steamdeck = True
|
self._is_steamdeck = True
|
||||||
logger.info("Steam Deck platform detected")
|
logger.info("Steam Deck/SteamOS platform detected")
|
||||||
else:
|
else:
|
||||||
logger.debug("Non-Steam Deck Linux platform detected")
|
logger.debug("Non-Steam Deck Linux platform detected")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -45,19 +45,20 @@ class SuccessDialog(QDialog):
|
|||||||
self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }" )
|
self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }" )
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setSpacing(0)
|
layout.setSpacing(0)
|
||||||
layout.setContentsMargins(30, 30, 30, 30)
|
layout.setContentsMargins(30, 20, 30, 20) # Reduced top/bottom margins to prevent truncation
|
||||||
|
|
||||||
# --- Card background for content ---
|
# --- Card background for content ---
|
||||||
card = QFrame(self)
|
card = QFrame(self)
|
||||||
card.setObjectName("successCard")
|
card.setObjectName("successCard")
|
||||||
card.setFrameShape(QFrame.StyledPanel)
|
card.setFrameShape(QFrame.StyledPanel)
|
||||||
card.setFrameShadow(QFrame.Raised)
|
card.setFrameShadow(QFrame.Raised)
|
||||||
card.setFixedWidth(440)
|
# Increase card width and reduce margins to maximize text width for 800p screens
|
||||||
card.setMinimumHeight(380)
|
card.setFixedWidth(460)
|
||||||
|
# Remove fixed minimum height to allow natural sizing based on content
|
||||||
card.setMaximumHeight(16777215) # Remove max height constraint to allow expansion
|
card.setMaximumHeight(16777215) # Remove max height constraint to allow expansion
|
||||||
card_layout = QVBoxLayout(card)
|
card_layout = QVBoxLayout(card)
|
||||||
card_layout.setSpacing(12)
|
card_layout.setSpacing(12)
|
||||||
card_layout.setContentsMargins(28, 28, 28, 28)
|
card_layout.setContentsMargins(20, 28, 20, 28) # Reduced left/right margins to give more text width
|
||||||
card.setStyleSheet(
|
card.setStyleSheet(
|
||||||
"QFrame#successCard { "
|
"QFrame#successCard { "
|
||||||
" background: #23272e; "
|
" background: #23272e; "
|
||||||
@@ -81,29 +82,38 @@ class SuccessDialog(QDialog):
|
|||||||
card_layout.addWidget(title_label)
|
card_layout.addWidget(title_label)
|
||||||
|
|
||||||
# Personalized success message (modlist name in Jackify Blue, but less bold)
|
# Personalized success message (modlist name in Jackify Blue, but less bold)
|
||||||
message_text = self._build_success_message()
|
|
||||||
modlist_name_html = f'<span style="color:#3fb7d6; font-size:17px; font-weight:500;">{self.modlist_name}</span>'
|
modlist_name_html = f'<span style="color:#3fb7d6; font-size:17px; font-weight:500;">{self.modlist_name}</span>'
|
||||||
if self.workflow_type == "install":
|
if self.workflow_type == "install":
|
||||||
message_html = f"<span style='font-size:15px;'>{modlist_name_html} installed successfully!</span>"
|
suffix_text = "installed successfully!"
|
||||||
|
elif self.workflow_type == "configure_new":
|
||||||
|
suffix_text = "configured successfully!"
|
||||||
|
elif self.workflow_type == "configure_existing":
|
||||||
|
suffix_text = "configuration updated successfully!"
|
||||||
else:
|
else:
|
||||||
message_html = message_text
|
# Fallback for other workflow types
|
||||||
|
message_text = self._build_success_message()
|
||||||
|
suffix_text = message_text.replace(self.modlist_name, "").strip()
|
||||||
|
|
||||||
|
# Build complete message with proper HTML formatting - ensure both parts are visible
|
||||||
|
message_html = f'{modlist_name_html} <span style="font-size:15px; color:#e0e0e0;">{suffix_text}</span>'
|
||||||
message_label = QLabel(message_html)
|
message_label = QLabel(message_html)
|
||||||
# Center the success message within the wider card for all screen sizes
|
# Center the success message within the wider card for all screen sizes
|
||||||
message_label.setAlignment(Qt.AlignCenter)
|
message_label.setAlignment(Qt.AlignCenter)
|
||||||
message_label.setWordWrap(True)
|
message_label.setWordWrap(True)
|
||||||
message_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
|
message_label.setMinimumHeight(30) # Ensure label has minimum height to be visible
|
||||||
|
message_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||||
message_label.setStyleSheet(
|
message_label.setStyleSheet(
|
||||||
"QLabel { "
|
"QLabel { "
|
||||||
" font-size: 15px; "
|
" font-size: 15px; "
|
||||||
" color: #e0e0e0; "
|
" color: #e0e0e0; "
|
||||||
" line-height: 1.3; "
|
" line-height: 1.3; "
|
||||||
" margin-bottom: 6px; "
|
" margin-bottom: 6px; "
|
||||||
" word-wrap: break-word; "
|
" padding: 0px; "
|
||||||
"}"
|
"}"
|
||||||
)
|
)
|
||||||
message_label.setTextFormat(Qt.RichText)
|
message_label.setTextFormat(Qt.RichText)
|
||||||
# Ensure the label itself is centered in the card layout and uses full width
|
# Ensure the label uses full width of the card before wrapping
|
||||||
card_layout.addWidget(message_label, alignment=Qt.AlignCenter)
|
card_layout.addWidget(message_label)
|
||||||
|
|
||||||
# Time taken
|
# Time taken
|
||||||
time_label = QLabel(f"Completed in {self.time_taken}")
|
time_label = QLabel(f"Completed in {self.time_taken}")
|
||||||
@@ -123,7 +133,7 @@ class SuccessDialog(QDialog):
|
|||||||
next_steps_label = QLabel(next_steps_text)
|
next_steps_label = QLabel(next_steps_text)
|
||||||
next_steps_label.setAlignment(Qt.AlignCenter)
|
next_steps_label.setAlignment(Qt.AlignCenter)
|
||||||
next_steps_label.setWordWrap(True)
|
next_steps_label.setWordWrap(True)
|
||||||
next_steps_label.setMinimumHeight(100)
|
# Remove fixed minimum height to allow natural sizing
|
||||||
next_steps_label.setStyleSheet(
|
next_steps_label.setStyleSheet(
|
||||||
"QLabel { "
|
"QLabel { "
|
||||||
" font-size: 13px; "
|
" font-size: 13px; "
|
||||||
|
|||||||
@@ -1495,7 +1495,8 @@ class JackifyMainWindow(QMainWindow):
|
|||||||
4: "Install Modlist Screen",
|
4: "Install Modlist Screen",
|
||||||
5: "Install TTW Screen",
|
5: "Install TTW Screen",
|
||||||
6: "Configure New Modlist",
|
6: "Configure New Modlist",
|
||||||
7: "Configure Existing Modlist",
|
7: "Wabbajack Installer",
|
||||||
|
8: "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)
|
||||||
@@ -1919,7 +1920,6 @@ def main():
|
|||||||
debug_mode = True
|
debug_mode = True
|
||||||
# Temporarily save CLI debug flag to config so engine can see it
|
# Temporarily save CLI debug flag to config so engine can see it
|
||||||
config_handler.set('debug_mode', True)
|
config_handler.set('debug_mode', True)
|
||||||
print("[DEBUG] CLI --debug flag detected, saved debug_mode=True to config")
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Initialize file logging on root logger so all modules inherit it
|
# Initialize file logging on root logger so all modules inherit it
|
||||||
@@ -1928,13 +1928,22 @@ def main():
|
|||||||
# Only rotate log file when debug mode is enabled
|
# Only rotate log file when debug mode is enabled
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-gui.log')
|
logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-gui.log')
|
||||||
root_logger = logging_handler.setup_logger('', 'jackify-gui.log', is_general=True) # Empty name = root logger
|
root_logger = logging_handler.setup_logger('', 'jackify-gui.log', is_general=True, debug_mode=debug_mode) # Empty name = root logger
|
||||||
|
|
||||||
|
# CRITICAL: Set root logger level BEFORE any child loggers are used
|
||||||
|
# This ensures DEBUG messages from child loggers propagate correctly
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
logging.getLogger().setLevel(logging.DEBUG)
|
root_logger.setLevel(logging.DEBUG)
|
||||||
print("[Jackify] Debug mode enabled (from config or CLI)")
|
logging.getLogger().setLevel(logging.DEBUG) # Also set on root via getLogger() for compatibility
|
||||||
|
root_logger.debug("CLI --debug flag detected, saved debug_mode=True to config")
|
||||||
|
root_logger.info("Debug mode enabled (from config or CLI)")
|
||||||
else:
|
else:
|
||||||
|
root_logger.setLevel(logging.WARNING)
|
||||||
logging.getLogger().setLevel(logging.WARNING)
|
logging.getLogger().setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
# Root logger should not propagate (it's the top level)
|
||||||
|
# Child loggers will propagate to root logger by default (unless they explicitly set propagate=False)
|
||||||
|
root_logger.propagate = False
|
||||||
|
|
||||||
dev_mode = '--dev' in sys.argv
|
dev_mode = '--dev' in sys.argv
|
||||||
|
|
||||||
@@ -1990,7 +1999,7 @@ def main():
|
|||||||
icon = QIcon(path)
|
icon = QIcon(path)
|
||||||
if not icon.isNull():
|
if not icon.isNull():
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
print(f"[DEBUG] Using AppImage icon: {path}")
|
logging.getLogger().debug(f"Using AppImage icon: {path}")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Priority 3: Fallback to any PNG in assets directory
|
# Priority 3: Fallback to any PNG in assets directory
|
||||||
@@ -2001,8 +2010,8 @@ def main():
|
|||||||
icon = QIcon(try_path)
|
icon = QIcon(try_path)
|
||||||
|
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
print(f"[DEBUG] Final icon path: {icon_path}")
|
logging.getLogger().debug(f"Final icon path: {icon_path}")
|
||||||
print(f"[DEBUG] Icon is null: {icon.isNull()}")
|
logging.getLogger().debug(f"Icon is null: {icon.isNull()}")
|
||||||
|
|
||||||
app.setWindowIcon(icon)
|
app.setWindowIcon(icon)
|
||||||
window = JackifyMainWindow(dev_mode=dev_mode)
|
window = JackifyMainWindow(dev_mode=dev_mode)
|
||||||
|
|||||||
@@ -648,7 +648,7 @@ class ConfigureExistingModlistScreen(QWidget):
|
|||||||
|
|
||||||
class ConfigurationThread(QThread):
|
class ConfigurationThread(QThread):
|
||||||
progress_update = Signal(str)
|
progress_update = Signal(str)
|
||||||
configuration_complete = Signal(bool, str, str)
|
configuration_complete = Signal(bool, str, str, bool)
|
||||||
error_occurred = Signal(str)
|
error_occurred = Signal(str)
|
||||||
|
|
||||||
def __init__(self, modlist_name, install_dir, resolution):
|
def __init__(self, modlist_name, install_dir, resolution):
|
||||||
@@ -691,8 +691,8 @@ class ConfigureExistingModlistScreen(QWidget):
|
|||||||
def progress_callback(message):
|
def progress_callback(message):
|
||||||
self.progress_update.emit(message)
|
self.progress_update.emit(message)
|
||||||
|
|
||||||
def completion_callback(success, message, modlist_name):
|
def completion_callback(success, message, modlist_name, enb_detected=False):
|
||||||
self.configuration_complete.emit(success, message, modlist_name)
|
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
|
||||||
|
|
||||||
def manual_steps_callback(modlist_name, retry_count):
|
def manual_steps_callback(modlist_name, retry_count):
|
||||||
# Existing modlists shouldn't need manual steps, but handle gracefully
|
# Existing modlists shouldn't need manual steps, but handle gracefully
|
||||||
@@ -729,7 +729,7 @@ class ConfigureExistingModlistScreen(QWidget):
|
|||||||
self._safe_append_text(f"[ERROR] Failed to start configuration: {e}")
|
self._safe_append_text(f"[ERROR] Failed to start configuration: {e}")
|
||||||
MessageService.critical(self, "Configuration Error", f"Failed to start configuration: {e}", safety_level="medium")
|
MessageService.critical(self, "Configuration Error", f"Failed to start configuration: {e}", safety_level="medium")
|
||||||
|
|
||||||
def on_configuration_complete(self, success, message, modlist_name):
|
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
|
||||||
"""Handle configuration completion"""
|
"""Handle configuration completion"""
|
||||||
# Re-enable all controls when workflow completes
|
# Re-enable all controls when workflow completes
|
||||||
self._enable_controls_after_operation()
|
self._enable_controls_after_operation()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicato
|
|||||||
from jackify.backend.handlers.progress_parser import ProgressStateManager
|
from jackify.backend.handlers.progress_parser import ProgressStateManager
|
||||||
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
|
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
|
||||||
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
|
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
|
||||||
from jackify.shared.progress_models import InstallationPhase, InstallationProgress, OperationType
|
from jackify.shared.progress_models import InstallationPhase, InstallationProgress, OperationType, FileProgress
|
||||||
# Modlist gallery (imported at module level to avoid import delay when opening dialog)
|
# Modlist gallery (imported at module level to avoid import delay when opening dialog)
|
||||||
from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog
|
from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog
|
||||||
|
|
||||||
@@ -2718,7 +2718,6 @@ class InstallModlistScreen(QWidget):
|
|||||||
# Render loop handles smooth updates - just set target state
|
# Render loop handles smooth updates - just set target state
|
||||||
|
|
||||||
current_step = progress_state.phase_step
|
current_step = progress_state.phase_step
|
||||||
from jackify.shared.progress_models import FileProgress, OperationType
|
|
||||||
|
|
||||||
display_items = []
|
display_items = []
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class InstallTTWScreen(QWidget):
|
|||||||
instruction_text = QLabel(
|
instruction_text = QLabel(
|
||||||
"Tale of Two Wastelands installation requires a .mpi file you can get from: "
|
"Tale of Two Wastelands installation requires a .mpi file you can get from: "
|
||||||
'<a href="https://mod.pub/ttw/133/files">https://mod.pub/ttw/133/files</a> '
|
'<a href="https://mod.pub/ttw/133/files">https://mod.pub/ttw/133/files</a> '
|
||||||
"(requires a user account for mod.db)"
|
"(requires a user account for ModPub)"
|
||||||
)
|
)
|
||||||
instruction_text.setWordWrap(True)
|
instruction_text.setWordWrap(True)
|
||||||
instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;")
|
instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;")
|
||||||
|
|||||||
@@ -209,7 +209,9 @@ class ModlistTasksScreen(QWidget):
|
|||||||
elif action_id == "configure_new_modlist":
|
elif action_id == "configure_new_modlist":
|
||||||
self.stacked_widget.setCurrentIndex(6) # Configure New Modlist Screen
|
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(7) # Configure Existing Modlist Screen
|
self.stacked_widget.setCurrentIndex(8) # Configure Existing Modlist Screen
|
||||||
|
elif action_id == "install_wabbajack":
|
||||||
|
self.stacked_widget.setCurrentIndex(7) # Wabbajack Installer Screen
|
||||||
|
|
||||||
def go_back(self):
|
def go_back(self):
|
||||||
"""Return to main menu"""
|
"""Return to main menu"""
|
||||||
|
|||||||
@@ -106,6 +106,77 @@ class OverallProgressIndicator(QWidget):
|
|||||||
if not display_text or display_text == "Processing...":
|
if not display_text or display_text == "Processing...":
|
||||||
display_text = progress.phase_name or progress.phase.value.title() or "Processing..."
|
display_text = progress.phase_name or progress.phase.value.title() or "Processing..."
|
||||||
|
|
||||||
|
# Add total download size, remaining size (MB/GB), and ETA for download phase
|
||||||
|
from jackify.shared.progress_models import InstallationPhase, FileProgress
|
||||||
|
if progress.phase == InstallationPhase.DOWNLOAD:
|
||||||
|
# Try to get overall download totals - either from data_total or aggregate from active_files
|
||||||
|
total_bytes = progress.data_total
|
||||||
|
processed_bytes = progress.data_processed
|
||||||
|
using_aggregated = False
|
||||||
|
|
||||||
|
# If data_total is 0, try to aggregate from active_files
|
||||||
|
if total_bytes == 0 and progress.active_files:
|
||||||
|
total_bytes = sum(f.total_size for f in progress.active_files if f.total_size > 0)
|
||||||
|
processed_bytes = sum(f.current_size for f in progress.active_files if f.current_size > 0)
|
||||||
|
using_aggregated = True
|
||||||
|
|
||||||
|
# Add remaining download size (MB or GB) if available
|
||||||
|
if total_bytes > 0:
|
||||||
|
remaining_bytes = total_bytes - processed_bytes
|
||||||
|
if remaining_bytes > 0:
|
||||||
|
# Format as MB if less than 1GB, otherwise GB
|
||||||
|
if remaining_bytes < (1024.0 ** 3):
|
||||||
|
remaining_mb = remaining_bytes / (1024.0 ** 2)
|
||||||
|
display_text += f" | {remaining_mb:.1f}MB remaining"
|
||||||
|
else:
|
||||||
|
remaining_gb = remaining_bytes / (1024.0 ** 3)
|
||||||
|
display_text += f" | {remaining_gb:.1f}GB remaining"
|
||||||
|
|
||||||
|
# Calculate ETA - prefer aggregated calculation for concurrent downloads
|
||||||
|
eta_seconds = -1.0
|
||||||
|
if using_aggregated:
|
||||||
|
# For concurrent downloads: sum all active download speeds (not average)
|
||||||
|
# This gives us the combined throughput
|
||||||
|
active_speeds = [f.speed for f in progress.active_files if f.speed > 0]
|
||||||
|
if active_speeds:
|
||||||
|
combined_speed = sum(active_speeds) # Sum speeds for concurrent downloads
|
||||||
|
if combined_speed > 0:
|
||||||
|
eta_seconds = remaining_bytes / combined_speed
|
||||||
|
else:
|
||||||
|
# Use the standard ETA calculation from progress model
|
||||||
|
eta_seconds = progress.get_eta_seconds(use_smoothing=True)
|
||||||
|
|
||||||
|
# Format and display ETA
|
||||||
|
if eta_seconds > 0:
|
||||||
|
if eta_seconds < 60:
|
||||||
|
display_text += f" | ETA: {int(eta_seconds)}s"
|
||||||
|
elif eta_seconds < 3600:
|
||||||
|
mins = int(eta_seconds // 60)
|
||||||
|
secs = int(eta_seconds % 60)
|
||||||
|
if secs > 0:
|
||||||
|
display_text += f" | ETA: {mins}m {secs}s"
|
||||||
|
else:
|
||||||
|
display_text += f" | ETA: {mins}m"
|
||||||
|
else:
|
||||||
|
hours = int(eta_seconds // 3600)
|
||||||
|
mins = int((eta_seconds % 3600) // 60)
|
||||||
|
if mins > 0:
|
||||||
|
display_text += f" | ETA: {hours}h {mins}m"
|
||||||
|
else:
|
||||||
|
display_text += f" | ETA: {hours}h"
|
||||||
|
else:
|
||||||
|
# No total size available - try to show ETA if we have speed info from active files
|
||||||
|
if progress.active_files:
|
||||||
|
active_speeds = [f.speed for f in progress.active_files if f.speed > 0]
|
||||||
|
if active_speeds:
|
||||||
|
# Can't calculate accurate ETA without total size, but could show speed
|
||||||
|
pass
|
||||||
|
# Fallback to standard ETA if available
|
||||||
|
if not using_aggregated:
|
||||||
|
eta_display = progress.eta_display
|
||||||
|
if eta_display:
|
||||||
|
display_text += f" | ETA: {eta_display}"
|
||||||
|
|
||||||
self.status_label.setText(display_text)
|
self.status_label.setText(display_text)
|
||||||
|
|
||||||
# Update progress bar if enabled
|
# Update progress bar if enabled
|
||||||
@@ -150,6 +221,23 @@ class OverallProgressIndicator(QWidget):
|
|||||||
tooltip_parts.append(f"Step: {progress.phase_progress_text}")
|
tooltip_parts.append(f"Step: {progress.phase_progress_text}")
|
||||||
if progress.data_progress_text:
|
if progress.data_progress_text:
|
||||||
tooltip_parts.append(f"Data: {progress.data_progress_text}")
|
tooltip_parts.append(f"Data: {progress.data_progress_text}")
|
||||||
|
|
||||||
|
# Add total download size in GB for download phase
|
||||||
|
from jackify.shared.progress_models import InstallationPhase
|
||||||
|
if progress.phase == InstallationPhase.DOWNLOAD and progress.data_total > 0:
|
||||||
|
total_gb = progress.total_download_size_gb
|
||||||
|
remaining_gb = progress.remaining_download_size_gb
|
||||||
|
if total_gb > 0:
|
||||||
|
tooltip_parts.append(f"Total Download: {total_gb:.2f}GB")
|
||||||
|
if remaining_gb > 0:
|
||||||
|
tooltip_parts.append(f"Remaining: {remaining_gb:.2f}GB")
|
||||||
|
|
||||||
|
# Add ETA for download phase
|
||||||
|
if progress.phase == InstallationPhase.DOWNLOAD:
|
||||||
|
eta_display = progress.eta_display
|
||||||
|
if eta_display:
|
||||||
|
tooltip_parts.append(f"Estimated Time Remaining: {eta_display}")
|
||||||
|
|
||||||
if progress.overall_percent > 0:
|
if progress.overall_percent > 0:
|
||||||
tooltip_parts.append(f"Overall: {progress.overall_percent:.1f}%")
|
tooltip_parts.append(f"Overall: {progress.overall_percent:.1f}%")
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import shutil
|
|||||||
class LoggingHandler:
|
class LoggingHandler:
|
||||||
"""
|
"""
|
||||||
Central logging handler for Jackify.
|
Central logging handler for Jackify.
|
||||||
- Uses configurable Jackify data directory for logs (default: ~/Jackify/logs/).
|
- Uses configured Jackify data directory for logs (default: ~/Jackify/logs/).
|
||||||
- Supports per-function log files (e.g., jackify-install-wabbajack.log).
|
- Supports per-function log files (e.g., jackify-install-wabbajack.log).
|
||||||
- Handles log rotation and log directory creation.
|
- Handles log rotation and log directory creation.
|
||||||
Usage:
|
Usage:
|
||||||
@@ -61,8 +61,15 @@ class LoggingHandler:
|
|||||||
file_path = self.log_dir / (log_file if log_file else "jackify-cli.log")
|
file_path = self.log_dir / (log_file if log_file else "jackify-cli.log")
|
||||||
self.rotate_log_file_per_run(file_path, backup_count=backup_count)
|
self.rotate_log_file_per_run(file_path, backup_count=backup_count)
|
||||||
|
|
||||||
def setup_logger(self, name: str, log_file: Optional[str] = None, is_general: bool = False) -> logging.Logger:
|
def setup_logger(self, name: str, log_file: Optional[str] = None, is_general: bool = False, debug_mode: Optional[bool] = None) -> logging.Logger:
|
||||||
"""Set up a logger with file and console handlers. Call rotate_log_for_logger before this if you want per-run rotation."""
|
"""Set up a logger with file and console handlers. Call rotate_log_for_logger before this if you want per-run rotation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Logger name (empty string for root logger)
|
||||||
|
log_file: Optional log file name
|
||||||
|
is_general: If True, use default log file name
|
||||||
|
debug_mode: Optional debug mode override. If None, reads from config.
|
||||||
|
"""
|
||||||
logger = logging.getLogger(name)
|
logger = logging.getLogger(name)
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
logger.propagate = False
|
logger.propagate = False
|
||||||
@@ -75,20 +82,21 @@ class LoggingHandler:
|
|||||||
'%(levelname)s: %(message)s'
|
'%(levelname)s: %(message)s'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add console handler - check debug mode from config
|
# Add console handler - use provided debug_mode or check config
|
||||||
console_handler = logging.StreamHandler()
|
console_handler = logging.StreamHandler()
|
||||||
|
|
||||||
# Check if debug mode is enabled
|
if debug_mode is None:
|
||||||
try:
|
# Check if debug mode is enabled from config
|
||||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
try:
|
||||||
config_handler = ConfigHandler()
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||||
debug_mode = config_handler.get('debug_mode', False)
|
config_handler = ConfigHandler()
|
||||||
if debug_mode:
|
debug_mode = config_handler.get('debug_mode', False)
|
||||||
console_handler.setLevel(logging.DEBUG)
|
except Exception:
|
||||||
else:
|
debug_mode = False
|
||||||
console_handler.setLevel(logging.ERROR)
|
|
||||||
except Exception:
|
if debug_mode:
|
||||||
# Fallback to ERROR level if config can't be loaded
|
console_handler.setLevel(logging.DEBUG)
|
||||||
|
else:
|
||||||
console_handler.setLevel(logging.ERROR)
|
console_handler.setLevel(logging.ERROR)
|
||||||
console_handler.setFormatter(console_formatter)
|
console_handler.setFormatter(console_formatter)
|
||||||
if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
|
if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
|
||||||
@@ -100,6 +108,7 @@ class LoggingHandler:
|
|||||||
file_handler = logging.handlers.RotatingFileHandler(
|
file_handler = logging.handlers.RotatingFileHandler(
|
||||||
file_path, mode='a', encoding='utf-8', maxBytes=1024*1024, backupCount=5
|
file_path, mode='a', encoding='utf-8', maxBytes=1024*1024, backupCount=5
|
||||||
)
|
)
|
||||||
|
# File handler always accepts DEBUG - root logger level controls what gets through
|
||||||
file_handler.setLevel(logging.DEBUG)
|
file_handler.setLevel(logging.DEBUG)
|
||||||
file_handler.setFormatter(file_formatter)
|
file_handler.setFormatter(file_formatter)
|
||||||
if not any(isinstance(h, logging.handlers.RotatingFileHandler) and getattr(h, 'baseFilename', None) == str(file_path) for h in logger.handlers):
|
if not any(isinstance(h, logging.handlers.RotatingFileHandler) and getattr(h, 'baseFilename', None) == str(file_path) for h in logger.handlers):
|
||||||
@@ -203,5 +212,5 @@ class LoggingHandler:
|
|||||||
return stats
|
return stats
|
||||||
|
|
||||||
def get_general_logger(self):
|
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)
|
return self.setup_logger('jackify_cli', is_general=True)
|
||||||
@@ -6,7 +6,7 @@ Used by both parser and GUI components.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional, Tuple
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -97,6 +97,11 @@ class InstallationProgress:
|
|||||||
texture_conversion_total: int = 0 # Total textures to convert
|
texture_conversion_total: int = 0 # Total textures to convert
|
||||||
bsa_building_current: int = 0 # Current BSA being built
|
bsa_building_current: int = 0 # Current BSA being built
|
||||||
bsa_building_total: int = 0 # Total BSAs to build
|
bsa_building_total: int = 0 # Total BSAs to build
|
||||||
|
# ETA smoothing: track speed and data history for stable ETA calculation
|
||||||
|
_speed_history: List[Tuple[float, float]] = field(default_factory=list) # [(timestamp, speed_bytes_per_sec), ...]
|
||||||
|
_data_history: List[Tuple[float, int]] = field(default_factory=list) # [(timestamp, data_processed_bytes), ...]
|
||||||
|
_last_eta_update: float = 0.0 # Last time ETA was calculated/displayed
|
||||||
|
_smoothed_eta_seconds: float = -1.0 # Cached smoothed ETA value
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
"""Ensure percent is in valid range."""
|
"""Ensure percent is in valid range."""
|
||||||
@@ -122,6 +127,185 @@ class InstallationProgress:
|
|||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_download_size_gb(self) -> float:
|
||||||
|
"""Get total download size in GB (0 if unknown)."""
|
||||||
|
if self.data_total > 0:
|
||||||
|
return self.data_total / (1024.0 ** 3)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_download_size_gb(self) -> float:
|
||||||
|
"""Get remaining download size in GB (0 if unknown or complete)."""
|
||||||
|
if self.data_total > 0 and self.data_processed < self.data_total:
|
||||||
|
return (self.data_total - self.data_processed) / (1024.0 ** 3)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _update_speed_history(self, operation: str, speed: float):
|
||||||
|
"""Update speed history for ETA smoothing."""
|
||||||
|
if operation.lower() != 'download':
|
||||||
|
return
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Add current speed to history
|
||||||
|
self._speed_history.append((current_time, speed))
|
||||||
|
|
||||||
|
# Keep only last 60 seconds of history
|
||||||
|
cutoff_time = current_time - 60.0
|
||||||
|
self._speed_history = [(t, s) for t, s in self._speed_history if t >= cutoff_time]
|
||||||
|
|
||||||
|
def _update_data_history(self):
|
||||||
|
"""Update data history for calculating average speed from data processed over time."""
|
||||||
|
if self.data_processed <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Only add if data has changed or enough time has passed (avoid spam)
|
||||||
|
if self._data_history:
|
||||||
|
last_time, last_data = self._data_history[-1]
|
||||||
|
# Only add if data changed by at least 1MB or 5 seconds passed
|
||||||
|
if self.data_processed == last_data and (current_time - last_time) < 5.0:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._data_history.append((current_time, self.data_processed))
|
||||||
|
|
||||||
|
# Keep only last 60 seconds
|
||||||
|
cutoff_time = current_time - 60.0
|
||||||
|
self._data_history = [(t, d) for t, d in self._data_history if t >= cutoff_time]
|
||||||
|
|
||||||
|
def _get_average_speed(self, window_seconds: float = 30.0) -> float:
|
||||||
|
"""
|
||||||
|
Get average download speed over the last N seconds.
|
||||||
|
Uses both speed history and data history for more accurate calculation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
window_seconds: Time window to average over (default 30 seconds)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Average speed in bytes per second, or -1 if insufficient data
|
||||||
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
cutoff_time = current_time - window_seconds
|
||||||
|
|
||||||
|
# Method 1: Use speed history if available
|
||||||
|
recent_speeds = [s for t, s in self._speed_history if t >= cutoff_time]
|
||||||
|
if len(recent_speeds) >= 3: # Need at least 3 samples
|
||||||
|
return sum(recent_speeds) / len(recent_speeds)
|
||||||
|
|
||||||
|
# Method 2: Calculate from data history (more accurate for varying speeds)
|
||||||
|
recent_data = [(t, d) for t, d in self._data_history if t >= cutoff_time]
|
||||||
|
if len(recent_data) >= 2:
|
||||||
|
# Calculate average speed from data processed over time
|
||||||
|
oldest = recent_data[0]
|
||||||
|
newest = recent_data[-1]
|
||||||
|
time_diff = newest[0] - oldest[0]
|
||||||
|
data_diff = newest[1] - oldest[1]
|
||||||
|
if time_diff > 0:
|
||||||
|
return data_diff / time_diff
|
||||||
|
|
||||||
|
# Fallback: Use current instantaneous speed
|
||||||
|
return self.get_speed('download')
|
||||||
|
|
||||||
|
def get_eta_seconds(self, use_smoothing: bool = True) -> float:
|
||||||
|
"""
|
||||||
|
Calculate estimated time remaining in seconds.
|
||||||
|
Uses smoothed/averaged speed to prevent wild fluctuations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
use_smoothing: If True, use averaged speed over last 30 seconds (default True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ETA in seconds, or -1 if ETA cannot be calculated
|
||||||
|
"""
|
||||||
|
# Only calculate ETA during download phase
|
||||||
|
if self.phase != InstallationPhase.DOWNLOAD:
|
||||||
|
return -1.0
|
||||||
|
|
||||||
|
# Need both remaining data and current speed
|
||||||
|
if self.data_total <= 0 or self.data_processed >= self.data_total:
|
||||||
|
return -1.0
|
||||||
|
|
||||||
|
# Update data history for speed calculation
|
||||||
|
self._update_data_history()
|
||||||
|
|
||||||
|
remaining_bytes = self.data_total - self.data_processed
|
||||||
|
|
||||||
|
# Get speed (smoothed or instantaneous)
|
||||||
|
if use_smoothing:
|
||||||
|
download_speed = self._get_average_speed(window_seconds=30.0)
|
||||||
|
else:
|
||||||
|
download_speed = self.get_speed('download')
|
||||||
|
|
||||||
|
if download_speed <= 0:
|
||||||
|
return -1.0
|
||||||
|
|
||||||
|
# Calculate ETA
|
||||||
|
eta_seconds = remaining_bytes / download_speed
|
||||||
|
|
||||||
|
# Apply exponential smoothing to ETA itself to prevent wild jumps
|
||||||
|
# Only update if we have a previous value and the change isn't too extreme
|
||||||
|
if use_smoothing and self._smoothed_eta_seconds > 0:
|
||||||
|
# If new ETA is wildly different (>50% change), use weighted average
|
||||||
|
# This prevents temporary speed drops from causing huge ETA jumps
|
||||||
|
change_ratio = abs(eta_seconds - self._smoothed_eta_seconds) / max(self._smoothed_eta_seconds, 1.0)
|
||||||
|
if change_ratio > 0.5:
|
||||||
|
# Large change - use 70% old, 30% new (smooth transition)
|
||||||
|
eta_seconds = 0.7 * self._smoothed_eta_seconds + 0.3 * eta_seconds
|
||||||
|
else:
|
||||||
|
# Small change - use 85% old, 15% new (quick but stable)
|
||||||
|
eta_seconds = 0.85 * self._smoothed_eta_seconds + 0.15 * eta_seconds
|
||||||
|
|
||||||
|
# Update cached value
|
||||||
|
self._smoothed_eta_seconds = eta_seconds
|
||||||
|
|
||||||
|
return eta_seconds
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_eta(seconds: float) -> str:
|
||||||
|
"""Format ETA seconds into human-readable string like '2h 15m' or '45m 30s'."""
|
||||||
|
if seconds < 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
hours = int(seconds // 3600)
|
||||||
|
minutes = int((seconds % 3600) // 60)
|
||||||
|
secs = int(seconds % 60)
|
||||||
|
|
||||||
|
if hours > 0:
|
||||||
|
if minutes > 0:
|
||||||
|
return f"{hours}h {minutes}m"
|
||||||
|
else:
|
||||||
|
return f"{hours}h"
|
||||||
|
elif minutes > 0:
|
||||||
|
if secs > 0:
|
||||||
|
return f"{minutes}m {secs}s"
|
||||||
|
else:
|
||||||
|
return f"{minutes}m"
|
||||||
|
else:
|
||||||
|
return f"{secs}s"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def eta_display(self) -> str:
|
||||||
|
"""
|
||||||
|
Get formatted ETA display string.
|
||||||
|
Only updates every 5 seconds to prevent UI flicker from rapid changes.
|
||||||
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Only recalculate ETA every 5 seconds to prevent wild fluctuations in display
|
||||||
|
if current_time - self._last_eta_update < 5.0 and self._smoothed_eta_seconds > 0:
|
||||||
|
# Use cached value if recently calculated
|
||||||
|
eta_seconds = self._smoothed_eta_seconds
|
||||||
|
else:
|
||||||
|
# Recalculate with smoothing
|
||||||
|
eta_seconds = self.get_eta_seconds(use_smoothing=True)
|
||||||
|
self._last_eta_update = current_time
|
||||||
|
|
||||||
|
if eta_seconds < 0:
|
||||||
|
return ""
|
||||||
|
return self._format_eta(eta_seconds)
|
||||||
|
|
||||||
def get_overall_speed_display(self) -> str:
|
def get_overall_speed_display(self) -> str:
|
||||||
"""Get overall speed display from aggregate speeds reported by engine."""
|
"""Get overall speed display from aggregate speeds reported by engine."""
|
||||||
def _fresh_speed(op_key: str) -> float:
|
def _fresh_speed(op_key: str) -> float:
|
||||||
@@ -312,4 +496,8 @@ class InstallationProgress:
|
|||||||
self.speeds[op_key] = max(0.0, speed)
|
self.speeds[op_key] = max(0.0, speed)
|
||||||
self.speed_timestamps[op_key] = time.time()
|
self.speed_timestamps[op_key] = time.time()
|
||||||
self.timestamp = time.time()
|
self.timestamp = time.time()
|
||||||
|
|
||||||
|
# Update speed history for ETA smoothing
|
||||||
|
if speed > 0:
|
||||||
|
self._update_speed_history(op_key, speed)
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ pycryptodome>=3.19.0
|
|||||||
# Configuration file handling
|
# Configuration file handling
|
||||||
PyYAML>=6.0
|
PyYAML>=6.0
|
||||||
|
|
||||||
# Steam VDF file parsing (latest available version)
|
# Steam VDF file parsing (actively maintained fork, used by Gentoo)
|
||||||
vdf>=3.4
|
vdf @ git+https://github.com/solsticegamestudios/vdf.git
|
||||||
|
|
||||||
# Package version handling
|
# Package version handling
|
||||||
packaging>=21.0
|
packaging>=21.0
|
||||||
|
|||||||
Reference in New Issue
Block a user