mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-19 12:17:45 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53af9f26a2 | ||
|
|
9000b1e080 | ||
|
|
02f3d71a82 |
40
CHANGELOG.md
40
CHANGELOG.md
@@ -1,8 +1,46 @@
|
||||
# Jackify Changelog
|
||||
|
||||
## v0.2.2 - VNV Automation and First-Launch Improvements
|
||||
**Release Date:** 2026-01-21
|
||||
|
||||
### Major Features
|
||||
- **Viva New Vegas Post-Install Automation (experimental)**: Full automated workflow for the Viva New Vegas modlist. Handles root files copying, 4GB patcher, and BSA decompression as per the VNV install guide. This is an initial pass at automating this, so considered experimental.
|
||||
- **Game Directory Pre-Creation**: Automatically creates My Documents/My Games and AppData/Local directories for some. Prevents some first-launch failures where games can't initialize under Proton. Supports Skyrim SE, FNV, FO4, Oblivion, Oblivion Remastered, Enderal, and Starfield so far.
|
||||
|
||||
### Bug Fixes
|
||||
- **Configure Existing Modlist**: Fixed AttributeError when VNV automation check runs after configuration completes
|
||||
- **Enderal Directory Creation**: Fixed bug where Enderal My Documents directory was created for all modlists instead of only Enderal
|
||||
|
||||
### Improvements
|
||||
- **Winetricks Bundling**: Implemented Wine wrapper scripts that replicate protontricks' environment setup for improved reliability
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
**Release Date:** 2025-01-12
|
||||
|
||||
Y
|
||||
### 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.
|
||||
- **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.
|
||||
"""
|
||||
|
||||
__version__ = "0.2.1"
|
||||
__version__ = "0.2.2"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
@@ -81,7 +81,7 @@ class LoggingHandler:
|
||||
if log_file or is_general:
|
||||
file_path = self.log_dir / (log_file if log_file else "jackify-cli.log")
|
||||
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=100*1024*1024, backupCount=5
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
@@ -90,7 +90,7 @@ class LoggingHandler:
|
||||
|
||||
return logger
|
||||
|
||||
def rotate_logs(self, max_bytes: int = 1024 * 1024, backup_count: int = 5) -> None:
|
||||
def rotate_logs(self, max_bytes: int = 100 * 1024 * 1024, backup_count: int = 5) -> None:
|
||||
"""Rotate log files based on size."""
|
||||
for log_file in self.get_log_files():
|
||||
try:
|
||||
@@ -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)
|
||||
@@ -667,6 +667,35 @@ class ModlistMenuHandler:
|
||||
self.logger.warning(f"ENB configuration skipped due to error: {e}")
|
||||
# Continue workflow - ENB config is optional
|
||||
|
||||
# Run modlist-specific post-install automation (e.g., VNV) before showing completion
|
||||
# Only in CLI mode - GUI handles this in install_modlist.py
|
||||
if not gui_mode:
|
||||
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
from pathlib import Path
|
||||
|
||||
modlist_name = context.get('name', '')
|
||||
modlist_path = Path(context.get('path', ''))
|
||||
|
||||
try:
|
||||
print("")
|
||||
print("Running VNV post-install automation...")
|
||||
automation_ran, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=modlist_name,
|
||||
modlist_install_location=modlist_path,
|
||||
game_root=None, # Will be auto-detected
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
progress_callback=lambda msg: print(msg),
|
||||
manual_file_callback=None, # CLI doesn't support manual file callback yet
|
||||
confirmation_callback=None # Will use default confirmation in CLI
|
||||
)
|
||||
if error:
|
||||
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
|
||||
print("You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"VNV automation check skipped: {e}")
|
||||
# Not an error - just means VNV automation wasn't applicable
|
||||
|
||||
print("")
|
||||
print("")
|
||||
print("") # Extra blank line before completion
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)")
|
||||
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()
|
||||
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}")
|
||||
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
|
||||
|
||||
return True
|
||||
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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1785,6 +1785,18 @@ echo Prefix creation complete.
|
||||
logger.error(f"Error setting Proton on shortcut: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_ttw_installer_path() -> Optional[Path]:
|
||||
"""Get path to TTW_Linux_Installer if available"""
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
ttw_path = get_jackify_data_dir() / "TTW_Linux_Installer" / "ttw_linux_gui"
|
||||
if ttw_path.exists():
|
||||
return ttw_path
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def run_working_workflow(self, shortcut_name: str, modlist_install_dir: str,
|
||||
final_exe_path: str, progress_callback=None, steamdeck: Optional[bool] = None) -> Tuple[bool, Optional[Path], Optional[int], Optional[str]]:
|
||||
"""
|
||||
@@ -1800,6 +1812,8 @@ echo Prefix creation complete.
|
||||
shortcut_name: Name for the Steam shortcut
|
||||
modlist_install_dir: Directory where the modlist is installed
|
||||
final_exe_path: Path to ModOrganizer.exe
|
||||
progress_callback: Optional callback for progress updates
|
||||
steamdeck: Optional Steam Deck detection override
|
||||
|
||||
Returns:
|
||||
Tuple of (success, prefix_path, appid, last_timestamp)
|
||||
@@ -1922,7 +1936,7 @@ echo Prefix creation complete.
|
||||
progress_callback(f"{self._get_progress_timestamp()} Injecting {special_game_type.upper()} game registry entries...")
|
||||
|
||||
if prefix_path:
|
||||
self._inject_game_registry_entries(str(prefix_path))
|
||||
self._inject_game_registry_entries(str(prefix_path), special_game_type)
|
||||
else:
|
||||
logger.warning("Could not find prefix path for registry injection")
|
||||
else:
|
||||
@@ -1930,6 +1944,16 @@ echo Prefix creation complete.
|
||||
if progress_callback:
|
||||
progress_callback(f"{self._get_progress_timestamp()} No special game registry injection needed")
|
||||
|
||||
# Step 5.5: Pre-create game-specific directories for all modlists
|
||||
logger.info(f"Step 5.5: Creating game-specific user directories")
|
||||
if progress_callback:
|
||||
progress_callback(f"{self._get_progress_timestamp()} Creating game user directories...")
|
||||
|
||||
if prefix_path:
|
||||
self._create_game_user_directories(str(prefix_path), special_game_type)
|
||||
else:
|
||||
logger.warning("Could not find prefix path for directory creation")
|
||||
|
||||
last_timestamp = self._get_progress_timestamp()
|
||||
logger.info(f" Working workflow completed successfully! AppID: {appid}, Prefix: {prefix_path}")
|
||||
if progress_callback:
|
||||
@@ -3057,19 +3081,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
|
||||
@@ -3249,7 +3274,7 @@ echo Prefix creation complete.
|
||||
logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
|
||||
return None
|
||||
|
||||
def _inject_game_registry_entries(self, modlist_compatdata_path: str):
|
||||
def _inject_game_registry_entries(self, modlist_compatdata_path: str, special_game_type: str):
|
||||
"""Detect and inject FNV/Enderal game paths and apply universal dotnet4.x compatibility fixes"""
|
||||
system_reg_path = os.path.join(modlist_compatdata_path, "pfx", "system.reg")
|
||||
if not os.path.exists(system_reg_path):
|
||||
@@ -3265,7 +3290,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
|
||||
@@ -3289,15 +3314,6 @@ echo Prefix creation complete.
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Updated registry entry for {config['name']}")
|
||||
|
||||
# Special handling for Enderal: Create required user directory
|
||||
if app_id == "976620": # Enderal Special Edition
|
||||
try:
|
||||
enderal_docs_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser", "Documents", "My Games", "Enderal Special Edition")
|
||||
os.makedirs(enderal_docs_path, exist_ok=True)
|
||||
logger.info(f"Created Enderal user directory: {enderal_docs_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create Enderal user directory: {e}")
|
||||
else:
|
||||
logger.warning(f"Failed to update registry entry for {config['name']}")
|
||||
else:
|
||||
@@ -3305,6 +3321,49 @@ echo Prefix creation complete.
|
||||
|
||||
logger.info("Game registry injection completed")
|
||||
|
||||
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str):
|
||||
"""
|
||||
Pre-create game-specific user directories to prevent first-launch issues.
|
||||
|
||||
Creates both My Documents/My Games and AppData/Local directories for the game.
|
||||
This prevents issues where games fail to create these on first launch under Proton.
|
||||
"""
|
||||
# Map game types to their directory names
|
||||
game_dir_names = {
|
||||
"skyrim": "Skyrim Special Edition",
|
||||
"fnv": "FalloutNV",
|
||||
"fo4": "Fallout4",
|
||||
"oblivion": "Oblivion",
|
||||
"oblivion_remastered": "Oblivion Remastered",
|
||||
"enderal": "Enderal Special Edition",
|
||||
"starfield": "Starfield"
|
||||
}
|
||||
|
||||
# Get the directory name for this game type
|
||||
game_dir_name = game_dir_names.get(special_game_type)
|
||||
if not game_dir_name:
|
||||
logger.debug(f"No user directory mapping for game type: {special_game_type}")
|
||||
return
|
||||
|
||||
base_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser")
|
||||
|
||||
directories_to_create = [
|
||||
os.path.join(base_path, "Documents", "My Games", game_dir_name),
|
||||
os.path.join(base_path, "AppData", "Local", game_dir_name)
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
for directory in directories_to_create:
|
||||
try:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
logger.info(f"Created user directory: {directory}")
|
||||
created_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create directory {directory}: {e}")
|
||||
|
||||
if created_count > 0:
|
||||
logger.info(f"Created {created_count} user directories for {game_dir_name}")
|
||||
|
||||
def _get_lorerim_preferred_proton(self):
|
||||
"""Get Lorerim's preferred Proton 9 version with specific priority order"""
|
||||
try:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
220
jackify/backend/services/nexus_download_service.py
Normal file
220
jackify/backend/services/nexus_download_service.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Nexus Download Service
|
||||
|
||||
Handles downloading mod files from Nexus Mods using OAuth authentication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import requests
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NexusDownloadService:
|
||||
"""Service for downloading files from Nexus Mods"""
|
||||
|
||||
NEXUS_API_BASE = "https://api.nexusmods.com/v1"
|
||||
|
||||
def __init__(self, auth_token: str):
|
||||
"""
|
||||
Initialize Nexus download service.
|
||||
|
||||
Args:
|
||||
auth_token: OAuth access token or API key
|
||||
"""
|
||||
self.auth_token = auth_token
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
"User-Agent": "jackify"
|
||||
}
|
||||
|
||||
def get_mod_files(self, game_domain: str, mod_id: int) -> Optional[list]:
|
||||
"""
|
||||
Get list of files for a mod.
|
||||
|
||||
Args:
|
||||
game_domain: Game domain (e.g., 'newvegas')
|
||||
mod_id: Mod ID number
|
||||
|
||||
Returns:
|
||||
List of file metadata dicts, or None if failed
|
||||
"""
|
||||
try:
|
||||
url = f"{self.NEXUS_API_BASE}/games/{game_domain}/mods/{mod_id}/files.json"
|
||||
|
||||
response = requests.get(url, headers=self.headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
files = data.get('files', [])
|
||||
|
||||
logger.info(f"Found {len(files)} files for mod {mod_id}")
|
||||
return files
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get mod files: {e}")
|
||||
return None
|
||||
|
||||
def get_download_link(self, game_domain: str, mod_id: int, file_id: int) -> Optional[str]:
|
||||
"""
|
||||
Get download link for a specific file.
|
||||
|
||||
Args:
|
||||
game_domain: Game domain (e.g., 'newvegas')
|
||||
mod_id: Mod ID number
|
||||
file_id: File ID number
|
||||
|
||||
Returns:
|
||||
Download URL, or None if failed
|
||||
"""
|
||||
try:
|
||||
url = f"{self.NEXUS_API_BASE}/games/{game_domain}/mods/{mod_id}/files/{file_id}/download_link.json"
|
||||
|
||||
response = requests.get(url, headers=self.headers, timeout=30)
|
||||
|
||||
# Check for specific error codes
|
||||
if response.status_code == 403:
|
||||
logger.error(f"Download link request forbidden (403) - Nexus Premium required for file {file_id}")
|
||||
return None
|
||||
elif response.status_code == 404:
|
||||
logger.error(f"Download link request not found (404) - file {file_id} may not exist")
|
||||
return None
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# API returns list of download servers
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
download_url = data[0].get('URI')
|
||||
logger.info(f"Got download link for file {file_id}")
|
||||
return download_url
|
||||
else:
|
||||
logger.error(f"No download link returned for file {file_id}")
|
||||
return None
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error(f"Failed to get download link: HTTP {e.response.status_code} - {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get download link: {e}")
|
||||
return None
|
||||
|
||||
def download_file(
|
||||
self,
|
||||
download_url: str,
|
||||
output_path: Path,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Download a file from Nexus.
|
||||
|
||||
Args:
|
||||
download_url: Download URL from get_download_link()
|
||||
output_path: Where to save the file
|
||||
progress_callback: Optional callback(downloaded_bytes, total_bytes)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
response = requests.get(download_url, stream=True, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
if progress_callback and total_size > 0:
|
||||
progress_callback(downloaded, total_size)
|
||||
|
||||
logger.info(f"Downloaded {output_path.name} ({downloaded} bytes)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Download failed: {e}")
|
||||
if output_path.exists():
|
||||
output_path.unlink()
|
||||
return False
|
||||
|
||||
def download_latest_file(
|
||||
self,
|
||||
game_domain: str,
|
||||
mod_id: int,
|
||||
output_dir: Path,
|
||||
file_name_filter: Optional[str] = None,
|
||||
progress_callback: Optional[Callable[[str], None]] = None
|
||||
) -> Tuple[bool, Optional[Path], str]:
|
||||
"""
|
||||
Download the latest file from a mod.
|
||||
|
||||
Args:
|
||||
game_domain: Game domain (e.g., 'newvegas')
|
||||
mod_id: Mod ID number
|
||||
output_dir: Directory to save file
|
||||
file_name_filter: Optional substring to filter files (e.g., 'linux', 'mpi')
|
||||
progress_callback: Optional callback for status updates
|
||||
|
||||
Returns:
|
||||
Tuple of (success, file_path, message)
|
||||
"""
|
||||
def update_progress(msg: str):
|
||||
if progress_callback:
|
||||
progress_callback(msg)
|
||||
logger.info(msg)
|
||||
|
||||
try:
|
||||
update_progress(f"Fetching file list for mod {mod_id}...")
|
||||
|
||||
files = self.get_mod_files(game_domain, mod_id)
|
||||
if not files:
|
||||
return False, None, "Failed to get mod file list"
|
||||
|
||||
# Filter files if requested
|
||||
if file_name_filter:
|
||||
filtered = [f for f in files if file_name_filter.lower() in f.get('file_name', '').lower()]
|
||||
if not filtered:
|
||||
return False, None, f"No files found matching '{file_name_filter}'"
|
||||
files = filtered
|
||||
|
||||
# Get the most recent file
|
||||
files.sort(key=lambda f: f.get('uploaded_timestamp', 0), reverse=True)
|
||||
latest_file = files[0]
|
||||
|
||||
file_id = latest_file['file_id']
|
||||
file_name = latest_file['file_name']
|
||||
|
||||
update_progress(f"Downloading {file_name}...")
|
||||
|
||||
download_url = self.get_download_link(game_domain, mod_id, file_id)
|
||||
if not download_url:
|
||||
return False, None, "Failed to get download link"
|
||||
|
||||
output_path = output_dir / file_name
|
||||
|
||||
def download_progress(downloaded, total):
|
||||
if total > 0:
|
||||
percent = (downloaded / total) * 100
|
||||
update_progress(f"Downloading: {percent:.1f}%")
|
||||
|
||||
success = self.download_file(download_url, output_path, download_progress)
|
||||
|
||||
if success:
|
||||
return True, output_path, f"Downloaded {file_name}"
|
||||
else:
|
||||
return False, None, "Download failed"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Download failed: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return False, None, error_msg
|
||||
@@ -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:
|
||||
|
||||
198
jackify/backend/services/vnv_integration_helper.py
Normal file
198
jackify/backend/services/vnv_integration_helper.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
VNV Integration Helper
|
||||
|
||||
Helper functions to integrate VNV post-install automation into modlist workflows.
|
||||
Handles detection, confirmation, and execution for:
|
||||
- Install Modlist
|
||||
- Configure New Modlist
|
||||
- Configure Existing Modlist
|
||||
"""
|
||||
|
||||
import logging
|
||||
import configparser
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Tuple
|
||||
|
||||
from .vnv_post_install_service import VNVPostInstallService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_bytearray_value(value: str) -> str:
|
||||
"""
|
||||
Parse Qt @ByteArray format to extract the actual string value.
|
||||
|
||||
Format: @ByteArray(Viva New Vegas Extended)
|
||||
Returns: Viva New Vegas Extended
|
||||
"""
|
||||
match = re.match(r'@ByteArray\((.*)\)', value)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return value
|
||||
|
||||
|
||||
def _check_modorganizer_ini_profile(modlist_install_location: Path) -> bool:
|
||||
"""
|
||||
Check ModOrganizer.ini for VNV profile names.
|
||||
|
||||
Args:
|
||||
modlist_install_location: Path to modlist installation directory
|
||||
|
||||
Returns:
|
||||
True if selected_profile is "Viva New Vegas" or "Viva New Vegas Extended"
|
||||
"""
|
||||
try:
|
||||
mo_ini_path = modlist_install_location / "ModOrganizer.ini"
|
||||
if not mo_ini_path.exists():
|
||||
logger.debug(f"ModOrganizer.ini not found at {mo_ini_path}")
|
||||
return False
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
# Read with UTF-8-sig to handle BOM
|
||||
config.read(mo_ini_path, encoding='utf-8-sig')
|
||||
|
||||
if 'General' not in config:
|
||||
logger.debug("No [General] section in ModOrganizer.ini")
|
||||
return False
|
||||
|
||||
selected_profile_raw = config.get('General', 'selected_profile', fallback='')
|
||||
if not selected_profile_raw:
|
||||
logger.debug("No selected_profile in ModOrganizer.ini")
|
||||
return False
|
||||
|
||||
# Parse @ByteArray format
|
||||
selected_profile = _parse_bytearray_value(selected_profile_raw)
|
||||
logger.debug(f"Found selected_profile: {selected_profile}")
|
||||
|
||||
# Check if it's one of the VNV profiles
|
||||
vnv_profiles = ["Viva New Vegas", "Viva New Vegas Extended"]
|
||||
return selected_profile in vnv_profiles
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking ModOrganizer.ini for VNV profile: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def should_offer_vnv_automation(modlist_name: str, modlist_install_location: Optional[Path] = None) -> bool:
|
||||
"""
|
||||
Check if VNV automation should be offered for this modlist.
|
||||
|
||||
Detection methods (in order of reliability):
|
||||
1. Check ModOrganizer.ini selected_profile (most reliable)
|
||||
2. Check modlist name for VNV patterns
|
||||
|
||||
Args:
|
||||
modlist_name: Name of the modlist
|
||||
modlist_install_location: Optional path to modlist installation directory
|
||||
|
||||
Returns:
|
||||
True if VNV automation should be offered
|
||||
"""
|
||||
# Method 1: Check ModOrganizer.ini profile (most reliable)
|
||||
if modlist_install_location:
|
||||
if _check_modorganizer_ini_profile(modlist_install_location):
|
||||
logger.info(f"VNV detected via ModOrganizer.ini profile in {modlist_install_location}")
|
||||
return True
|
||||
|
||||
# Method 2: Check modlist name patterns
|
||||
modlist_name_lower = modlist_name.lower()
|
||||
vnv_patterns = [
|
||||
"viva new vegas",
|
||||
"vnv", # Common abbreviation
|
||||
"viva new vegas extended"
|
||||
]
|
||||
|
||||
for pattern in vnv_patterns:
|
||||
if pattern in modlist_name_lower:
|
||||
logger.info(f"VNV detected via name pattern '{pattern}' in '{modlist_name}'")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def run_vnv_automation_if_applicable(
|
||||
modlist_name: str,
|
||||
modlist_install_location: Path,
|
||||
game_root: Path,
|
||||
ttw_installer_path: Optional[Path] = None,
|
||||
progress_callback: Optional[Callable[[str], None]] = None,
|
||||
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None,
|
||||
confirmation_callback: Optional[Callable[[str], bool]] = None
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if VNV automation should run, get user confirmation, and execute if confirmed.
|
||||
|
||||
Args:
|
||||
modlist_name: Name of the installed modlist
|
||||
modlist_install_location: Path to modlist installation
|
||||
game_root: Path to game root directory
|
||||
ttw_installer_path: Optional path to TTW_Linux_Installer (for BSA decompression)
|
||||
progress_callback: Optional callback for progress updates
|
||||
manual_file_callback: Optional callback for manual file selection (non-Premium)
|
||||
confirmation_callback: Optional callback for user confirmation
|
||||
Takes description string, returns True if user confirms
|
||||
|
||||
Returns:
|
||||
Tuple of (automation_was_run: bool, error_message: Optional[str])
|
||||
"""
|
||||
try:
|
||||
# Check if this is VNV (pass install location for ModOrganizer.ini check)
|
||||
if not should_offer_vnv_automation(modlist_name, modlist_install_location):
|
||||
logger.debug(f"Modlist '{modlist_name}' does not require VNV automation")
|
||||
return False, None
|
||||
|
||||
logger.info(f"VNV detected: {modlist_name}")
|
||||
|
||||
# Initialize service
|
||||
vnv_service = VNVPostInstallService(
|
||||
modlist_install_location=modlist_install_location,
|
||||
game_root=game_root,
|
||||
ttw_installer_path=ttw_installer_path
|
||||
)
|
||||
|
||||
# Check what's already done
|
||||
completed = vnv_service.check_already_completed()
|
||||
# Only skip if ALL three steps are completed
|
||||
if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed']:
|
||||
logger.info("VNV automation steps already completed")
|
||||
if progress_callback:
|
||||
progress_callback("VNV post-install steps already completed")
|
||||
return False, None
|
||||
|
||||
# Get confirmation from user (required)
|
||||
if not confirmation_callback:
|
||||
logger.error("VNV automation requires confirmation_callback")
|
||||
return False, "VNV automation requires user confirmation"
|
||||
|
||||
if confirmation_callback:
|
||||
description = vnv_service.get_automation_description()
|
||||
if not confirmation_callback(description):
|
||||
logger.info("User declined VNV automation")
|
||||
if progress_callback:
|
||||
progress_callback("VNV automation skipped by user")
|
||||
return False, None
|
||||
|
||||
# Run automation
|
||||
logger.info("Starting VNV post-install automation")
|
||||
if progress_callback:
|
||||
progress_callback("Running VNV post-install automation...")
|
||||
|
||||
success, message = vnv_service.run_all_steps(
|
||||
progress_callback=progress_callback,
|
||||
manual_file_callback=manual_file_callback
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"VNV automation completed: {message}")
|
||||
if progress_callback:
|
||||
progress_callback(f"VNV automation: {message}")
|
||||
return True, None
|
||||
else:
|
||||
logger.error(f"VNV automation failed: {message}")
|
||||
return True, message
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"VNV automation error: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return True, error_msg
|
||||
622
jackify/backend/services/vnv_post_install_service.py
Normal file
622
jackify/backend/services/vnv_post_install_service.py
Normal file
@@ -0,0 +1,622 @@
|
||||
"""
|
||||
Viva New Vegas Post-Install Service
|
||||
|
||||
Automates the post-installation steps required for Viva New Vegas modlist:
|
||||
1. Root Mods - Copy files from '__Files Requiring Manual Install' to game root
|
||||
2. 4GB Patcher - Download Linux version from Nexus, run natively
|
||||
3. BSA Decompression - Download FNV BSA Decompressor MPI, run via TTW_Linux_Installer
|
||||
|
||||
These steps are documented at: https://vivanewvegas.moddinglinked.com/wabbajack.html
|
||||
|
||||
Uses native Linux tools (no Wine required) by downloading from Nexus with OAuth.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
|
||||
from ..handlers.subprocess_utils import get_clean_subprocess_env
|
||||
from .nexus_download_service import NexusDownloadService
|
||||
from .nexus_auth_service import NexusAuthService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VNVPostInstallService:
|
||||
"""Handles automated post-installation tasks for Viva New Vegas modlist."""
|
||||
|
||||
# Nexus mod IDs for required tools
|
||||
LINUX_4GB_PATCHER_MOD_ID = 62552
|
||||
FNV_BSA_DECOMPRESSOR_MOD_ID = 65854
|
||||
GAME_DOMAIN = "newvegas"
|
||||
|
||||
def __init__(self, modlist_install_location: Path, game_root: Path,
|
||||
ttw_installer_path: Optional[Path] = None):
|
||||
"""
|
||||
Initialize VNV post-install service.
|
||||
|
||||
Args:
|
||||
modlist_install_location: Path to the VNV installation (e.g., ~/VNV)
|
||||
game_root: Path to Fallout New Vegas game root
|
||||
ttw_installer_path: Path to TTW_Linux_Installer executable (for BSA decompression)
|
||||
"""
|
||||
self.modlist_install = modlist_install_location
|
||||
self.game_root = game_root
|
||||
self.ttw_installer_path = ttw_installer_path
|
||||
|
||||
# VNV-specific paths
|
||||
self.manual_install_dir = self.modlist_install / "__Files Requiring Manual Install"
|
||||
|
||||
# Download cache directory
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
self.cache_dir = get_jackify_data_dir() / "vnv_post_install_cache"
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize authentication
|
||||
self.auth_service = NexusAuthService()
|
||||
self.download_service = None
|
||||
|
||||
def _ensure_auth(self, progress_callback: Optional[Callable[[str], None]] = None) -> bool:
|
||||
"""
|
||||
Ensure we have valid Nexus authentication for downloads.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback for progress updates
|
||||
|
||||
Returns:
|
||||
True if authenticated
|
||||
"""
|
||||
auth_token = self.auth_service.ensure_valid_auth()
|
||||
|
||||
if not auth_token:
|
||||
if progress_callback:
|
||||
progress_callback("Nexus authentication required for post-install steps")
|
||||
logger.error("No Nexus authentication available")
|
||||
return False
|
||||
|
||||
self.download_service = NexusDownloadService(auth_token)
|
||||
return True
|
||||
|
||||
def should_run_automation(self, modlist_name: str) -> bool:
|
||||
"""
|
||||
Check if this modlist should trigger VNV automation.
|
||||
|
||||
Args:
|
||||
modlist_name: Name of the installed modlist
|
||||
|
||||
Returns:
|
||||
True if VNV automation should be offered
|
||||
"""
|
||||
return "viva new vegas" in modlist_name.lower()
|
||||
|
||||
def get_automation_description(self) -> str:
|
||||
"""
|
||||
Get user-friendly description of what VNV automation does.
|
||||
|
||||
Returns:
|
||||
Description string for confirmation dialog
|
||||
"""
|
||||
return (
|
||||
"Viva New Vegas Automation\n\n"
|
||||
"Jackify can automatically perform the following post-install steps:\n\n"
|
||||
"1. Copy root mods to game directory\n"
|
||||
"2. Download and run Linux 4GB patcher\n"
|
||||
"3. Download and run BSA decompressor (reduces loading times)\n\n"
|
||||
"Premium users: Downloads happen automatically\n"
|
||||
"Non-Premium users: You'll be prompted to download files manually\n\n"
|
||||
"Would you like Jackify to automate these steps?"
|
||||
)
|
||||
|
||||
def check_already_completed(self) -> dict:
|
||||
"""
|
||||
Check which VNV automation steps have already been completed.
|
||||
|
||||
Returns:
|
||||
Dict with keys: 'root_mods', '4gb_patch', 'bsa_decompressed'
|
||||
"""
|
||||
# Check if 4GB patch already applied
|
||||
backup_exe = self.game_root / "FalloutNV_backup.exe"
|
||||
already_patched = backup_exe.exists()
|
||||
|
||||
# Check if root mods copied (look for FNVpatch.exe in game root)
|
||||
root_mods_copied = (self.game_root / "FNVpatch.exe").exists()
|
||||
|
||||
# Check for BSA decompression marker file
|
||||
marker_file = self.game_root / ".jackify_bsa_decompressed"
|
||||
bsa_decompressed = marker_file.exists()
|
||||
|
||||
return {
|
||||
'root_mods': root_mods_copied,
|
||||
'4gb_patch': already_patched,
|
||||
'bsa_decompressed': bsa_decompressed
|
||||
}
|
||||
|
||||
def run_all_steps(self, progress_callback: Optional[Callable[[str], None]] = None,
|
||||
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None,
|
||||
skip_confirmation: bool = False) -> tuple[bool, str]:
|
||||
"""
|
||||
Run all VNV post-install steps in sequence.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback for progress updates
|
||||
manual_file_callback: Optional callback for manual file selection (non-Premium users)
|
||||
Takes (title, instructions) returns Path or None
|
||||
skip_confirmation: Skip user confirmation (for programmatic use)
|
||||
|
||||
Returns:
|
||||
(success: bool, message: str)
|
||||
"""
|
||||
def update_progress(msg: str):
|
||||
if progress_callback:
|
||||
progress_callback(msg)
|
||||
logger.info(msg)
|
||||
|
||||
try:
|
||||
# Ensure authentication
|
||||
update_progress("Checking Nexus authentication...")
|
||||
if not self._ensure_auth(progress_callback):
|
||||
return False, "Nexus authentication required. Please authenticate in Settings."
|
||||
|
||||
# Step 1: Copy root mods
|
||||
update_progress("Step 1/3: Copying root mods to game directory...")
|
||||
success, msg = self.copy_root_mods()
|
||||
if not success:
|
||||
return False, f"Root mods failed: {msg}"
|
||||
update_progress(f"Root mods: {msg}")
|
||||
|
||||
# Step 2: Run 4GB patcher
|
||||
update_progress("Step 2/3: Downloading and running 4GB patcher...")
|
||||
success, msg = self.run_4gb_patcher(update_progress, manual_file_callback)
|
||||
if not success:
|
||||
return False, f"4GB patcher failed: {msg}"
|
||||
update_progress(f"4GB patcher: {msg}")
|
||||
|
||||
# Step 3: Run BSA decompressor
|
||||
update_progress("Step 3/3: Downloading and running BSA decompressor...")
|
||||
success, msg = self.run_bsa_decompressor(update_progress, manual_file_callback)
|
||||
if not success:
|
||||
return False, f"BSA decompression failed: {msg}"
|
||||
update_progress(f"BSA decompression: {msg}")
|
||||
|
||||
return True, "VNV post-install completed successfully"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"VNV post-install failed: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return False, error_msg
|
||||
|
||||
def copy_root_mods(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Copy files from '__Files Requiring Manual Install' to game root.
|
||||
|
||||
Returns:
|
||||
(success: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
if not self.manual_install_dir.exists():
|
||||
return False, f"Manual install directory not found: {self.manual_install_dir}"
|
||||
|
||||
if not self.game_root.exists():
|
||||
return False, f"Game root directory not found: {self.game_root}"
|
||||
|
||||
# Copy all files from manual install to game root
|
||||
copied_files = []
|
||||
for item in self.manual_install_dir.iterdir():
|
||||
dest = self.game_root / item.name
|
||||
|
||||
if item.is_file():
|
||||
shutil.copy2(item, dest)
|
||||
copied_files.append(item.name)
|
||||
logger.debug(f"Copied: {item.name}")
|
||||
elif item.is_dir():
|
||||
# Merge directories to preserve vanilla game files (e.g., BSA files in Data/)
|
||||
# dirs_exist_ok=True allows adding NVSE to Data/ without deleting vanilla BSAs
|
||||
shutil.copytree(item, dest, dirs_exist_ok=True)
|
||||
copied_files.append(f"{item.name}/")
|
||||
logger.debug(f"Copied directory: {item.name}/")
|
||||
|
||||
if not copied_files:
|
||||
return False, "No files found to copy"
|
||||
|
||||
logger.info(f"Copied {len(copied_files)} items to game root")
|
||||
return True, f"Copied {len(copied_files)} items to game root"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to copy root mods: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return False, error_msg
|
||||
|
||||
def run_4gb_patcher(self, progress_callback: Optional[Callable[[str], None]] = None,
|
||||
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None) -> tuple[bool, str]:
|
||||
"""
|
||||
Download and run native Linux 4GB patcher.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback for progress updates
|
||||
manual_file_callback: Optional callback for manual file selection
|
||||
Takes (title, instructions) returns Path or None
|
||||
|
||||
Returns:
|
||||
(success: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
# Check if already patched
|
||||
backup_exe = self.game_root / "FalloutNV_backup.exe"
|
||||
if backup_exe.exists():
|
||||
logger.info("Game already has 4GB patch (backup exists)")
|
||||
return True, "Game already patched (backup exists)"
|
||||
|
||||
# Check cache first - look for extracted executable or zip
|
||||
patcher_path = None
|
||||
cached_extracted = list(self.cache_dir.glob("*4gb*_extracted/*"))
|
||||
if cached_extracted:
|
||||
# Use already extracted executable
|
||||
for f in cached_extracted:
|
||||
if f.is_file():
|
||||
patcher_path = f
|
||||
logger.info(f"Using cached extracted 4GB patcher: {patcher_path}")
|
||||
break
|
||||
|
||||
if not patcher_path:
|
||||
cached_files = list(self.cache_dir.glob("*4gb*.zip"))
|
||||
if cached_files:
|
||||
patcher_path = cached_files[0]
|
||||
logger.info(f"Using cached 4GB patcher zip: {patcher_path}")
|
||||
|
||||
if not patcher_path:
|
||||
# Try to download from Nexus
|
||||
# Note: The Linux version is named "FNV4GB for Proton", not "linux"
|
||||
success, patcher_path, msg = self.download_service.download_latest_file(
|
||||
self.GAME_DOMAIN,
|
||||
self.LINUX_4GB_PATCHER_MOD_ID,
|
||||
self.cache_dir,
|
||||
file_name_filter="proton",
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
if not success:
|
||||
# Download failed - offer manual download
|
||||
logger.error(f"Automatic download failed: {msg}")
|
||||
logger.debug(f"Looking for file with 'proton' in name on mod {self.LINUX_4GB_PATCHER_MOD_ID}")
|
||||
|
||||
if not manual_file_callback:
|
||||
return False, f"Failed to download 4GB patcher: {msg}\n\nPlease download manually from: https://www.nexusmods.com/newvegas/mods/62552"
|
||||
|
||||
instructions = (
|
||||
"Automatic download failed (requires Nexus Premium).\n\n"
|
||||
"Please download the Linux 4GB Patcher manually:\n\n"
|
||||
"1. Visit: https://www.nexusmods.com/newvegas/mods/62552\n\n"
|
||||
"2. Download the file named 'FNV4GB for Linux'\n\n"
|
||||
"3. Select the downloaded file below"
|
||||
)
|
||||
|
||||
patcher_path = manual_file_callback("4GB Patcher Required", instructions)
|
||||
|
||||
if not patcher_path or not patcher_path.exists():
|
||||
return False, "4GB patcher file not provided"
|
||||
|
||||
# Copy to cache for future use
|
||||
cached_path = self.cache_dir / patcher_path.name
|
||||
shutil.copy2(patcher_path, cached_path)
|
||||
patcher_path = cached_path
|
||||
logger.info(f"Using manually selected 4GB patcher: {patcher_path}")
|
||||
|
||||
# Extract if it's a zip file and not already extracted
|
||||
if patcher_path.suffix.lower() == '.zip':
|
||||
extract_dir = self.cache_dir / f"{patcher_path.stem}_extracted"
|
||||
|
||||
# Extract if not already done
|
||||
if not extract_dir.exists():
|
||||
logger.info(f"Extracting {patcher_path.name}...")
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(patcher_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(extract_dir)
|
||||
logger.info(f"Extracted to {extract_dir}")
|
||||
|
||||
# Find the executable
|
||||
executables = list(extract_dir.glob("*"))
|
||||
if not executables:
|
||||
return False, "No files found in 4GB patcher zip"
|
||||
|
||||
# Look for executable file (FalloutNVPatcher or similar)
|
||||
patcher_exe = None
|
||||
for f in executables:
|
||||
if f.is_file() and ('fallout' in f.name.lower() or 'patcher' in f.name.lower() or 'fnv' in f.name.lower()):
|
||||
patcher_exe = f
|
||||
break
|
||||
|
||||
if not patcher_exe:
|
||||
# Use first file if no obvious match
|
||||
patcher_exe = next((f for f in executables if f.is_file()), None)
|
||||
|
||||
if not patcher_exe:
|
||||
return False, "No executable found in 4GB patcher zip"
|
||||
|
||||
patcher_path = patcher_exe
|
||||
logger.info(f"Using patcher executable: {patcher_path.name}")
|
||||
|
||||
# Make executable
|
||||
patcher_path.chmod(patcher_path.stat().st_mode | stat.S_IEXEC)
|
||||
|
||||
# Run patcher
|
||||
if progress_callback:
|
||||
progress_callback("Running 4GB patcher...")
|
||||
|
||||
result = subprocess.run(
|
||||
[str(patcher_path)],
|
||||
cwd=str(self.game_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
# Check if backup was created (indicates success)
|
||||
if backup_exe.exists():
|
||||
logger.info("4GB patch applied successfully")
|
||||
return True, "4GB patch applied successfully"
|
||||
else:
|
||||
logger.warning(f"Patcher output: {result.stdout}")
|
||||
if result.stderr:
|
||||
logger.warning(f"Patcher errors: {result.stderr}")
|
||||
return False, "Patcher ran but FalloutNV_backup.exe not created"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "4GB patcher timed out after 60 seconds"
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to run 4GB patcher: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return False, error_msg
|
||||
|
||||
def run_bsa_decompressor(self, progress_callback: Optional[Callable[[str], None]] = None,
|
||||
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None) -> tuple[bool, str]:
|
||||
"""
|
||||
Download FNV BSA Decompressor MPI and run via TTW_Linux_Installer.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback for progress updates
|
||||
manual_file_callback: Optional callback for manual file selection
|
||||
Takes (title, instructions) returns Path or None
|
||||
|
||||
Returns:
|
||||
(success: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
# Check if already completed
|
||||
marker_file = self.game_root / ".jackify_bsa_decompressed"
|
||||
if marker_file.exists():
|
||||
logger.info("BSA decompression already completed (marker file exists)")
|
||||
return True, "BSA decompression already completed"
|
||||
|
||||
if not self.ttw_installer_path or not self.ttw_installer_path.exists():
|
||||
logger.warning("TTW_Linux_Installer not found, skipping BSA decompression")
|
||||
return True, "BSA decompression skipped (TTW_Linux_Installer not available)"
|
||||
|
||||
# Check cache first
|
||||
cached_files = list(self.cache_dir.glob("*BSA*.mpi"))
|
||||
if cached_files:
|
||||
mpi_path = cached_files[0]
|
||||
logger.info(f"Using cached BSA Decompressor MPI: {mpi_path}")
|
||||
else:
|
||||
# Also check for exact filename match (handles spaces in filename)
|
||||
exact_path = self.cache_dir / "FNV BSA Decompressor.mpi"
|
||||
if exact_path.exists():
|
||||
mpi_path = exact_path
|
||||
logger.info(f"Using cached BSA Decompressor MPI: {mpi_path}")
|
||||
else:
|
||||
# Try to download from Nexus
|
||||
success, mpi_path, msg = self.download_service.download_latest_file(
|
||||
self.GAME_DOMAIN,
|
||||
self.FNV_BSA_DECOMPRESSOR_MOD_ID,
|
||||
self.cache_dir,
|
||||
file_name_filter="mpi",
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
if not success:
|
||||
# Download failed - offer manual download
|
||||
logger.warning(f"Automatic download failed: {msg}")
|
||||
|
||||
if not manual_file_callback:
|
||||
return False, f"Failed to download BSA Decompressor MPI: {msg}\n\nPlease download manually from: https://www.nexusmods.com/newvegas/mods/65854"
|
||||
|
||||
instructions = (
|
||||
"Automatic download failed (requires Nexus Premium).\n\n"
|
||||
"Please download the FNV BSA Decompressor manually:\n"
|
||||
"1. Visit: https://www.nexusmods.com/newvegas/mods/65854\n"
|
||||
"2. Download the .mpi file\n"
|
||||
"3. Select the downloaded file below"
|
||||
)
|
||||
|
||||
mpi_path = manual_file_callback("BSA Decompressor Required", instructions)
|
||||
|
||||
if not mpi_path or not mpi_path.exists():
|
||||
return False, "BSA Decompressor MPI file not provided"
|
||||
|
||||
# Validate it's an MPI file
|
||||
if not mpi_path.suffix.lower() == '.mpi':
|
||||
return False, f"Selected file is not an MPI file: {mpi_path}"
|
||||
|
||||
# Copy to cache for future use
|
||||
cached_path = self.cache_dir / mpi_path.name
|
||||
shutil.copy2(mpi_path, cached_path)
|
||||
mpi_path = cached_path
|
||||
logger.info(f"Using manually selected BSA Decompressor MPI: {mpi_path}")
|
||||
|
||||
# Create temp output directory
|
||||
with tempfile.TemporaryDirectory() as temp_output:
|
||||
temp_output_path = Path(temp_output)
|
||||
|
||||
# Create config file for TTW_Linux_Installer (handles spaces in paths better)
|
||||
config_file = self.ttw_installer_path.parent / "ttw-config.json"
|
||||
import json
|
||||
config_data = {
|
||||
"FalloutNVRoot": str(self.game_root),
|
||||
"MpiPackagePath": str(mpi_path),
|
||||
"DestinationPath": str(temp_output_path)
|
||||
}
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
logger.debug(f"Created MPI config file: {config_file}")
|
||||
|
||||
# Run via TTW_Linux_Installer
|
||||
if progress_callback:
|
||||
progress_callback("Running BSA decompressor...")
|
||||
|
||||
cmd = [
|
||||
str(self.ttw_installer_path),
|
||||
"--start"
|
||||
]
|
||||
|
||||
logger.info(f"Running BSA decompressor: {' '.join(cmd)}")
|
||||
logger.debug(f"Using config file: {config_file}")
|
||||
logger.debug(f"Config: {json.dumps(config_data, indent=2)}")
|
||||
|
||||
env = get_clean_subprocess_env()
|
||||
|
||||
# Stream output and parse progress
|
||||
import re
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(self.ttw_installer_path.parent),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
# Pattern to match progress: "Assets processed: 12345/48649"
|
||||
progress_pattern = re.compile(r'Assets processed: (\d+)/(\d+)')
|
||||
last_progress = None
|
||||
|
||||
# Capture all output for diagnostics
|
||||
all_output = []
|
||||
already_modified_detected = False
|
||||
|
||||
# Stream output line by line
|
||||
for line in process.stdout:
|
||||
line = line.rstrip()
|
||||
all_output.append(line)
|
||||
|
||||
# Check for "already modified" messages
|
||||
if "already" in line.lower() and ("modified" in line.lower() or "decompressed" in line.lower()):
|
||||
already_modified_detected = True
|
||||
logger.info(f"BSA decompressor reports: {line}")
|
||||
|
||||
# Check for progress updates
|
||||
match = progress_pattern.search(line)
|
||||
if match:
|
||||
current = int(match.group(1))
|
||||
total = int(match.group(2))
|
||||
percent = (current / total * 100) if total > 0 else 0
|
||||
progress_msg = f"Decompressing BSA files: {current}/{total} ({percent:.1f}%)"
|
||||
|
||||
# Only send update if progress changed significantly
|
||||
if last_progress is None or current - last_progress >= total // 100:
|
||||
if progress_callback:
|
||||
progress_callback(progress_msg)
|
||||
# Log progress updates (not every single file)
|
||||
logger.debug(f"BSA decompression progress: {current}/{total} ({percent:.1f}%)")
|
||||
last_progress = current
|
||||
|
||||
# Wait for process to complete
|
||||
return_code = process.wait(timeout=600)
|
||||
|
||||
# Log full output for debugging failures
|
||||
if return_code != 0:
|
||||
logger.debug(f"BSA decompressor output:\n" + "\n".join(all_output[-50:])) # Last 50 lines
|
||||
|
||||
# Clean up config file after execution
|
||||
try:
|
||||
if config_file.exists():
|
||||
config_file.unlink()
|
||||
logger.debug(f"Cleaned up config file: {config_file}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up config file: {e}")
|
||||
|
||||
if return_code == 0:
|
||||
# Check if files were actually extracted to temp directory
|
||||
extracted_files = list(temp_output_path.rglob("*"))
|
||||
if extracted_files:
|
||||
logger.info(f"BSA decompression extracted {len(extracted_files)} files")
|
||||
|
||||
# Copy extracted files back to game Data directory
|
||||
data_dir = self.game_root / "Data"
|
||||
copied_count = 0
|
||||
for extracted_file in extracted_files:
|
||||
if extracted_file.is_file():
|
||||
# Preserve relative path structure
|
||||
relative_path = extracted_file.relative_to(temp_output_path)
|
||||
dest_file = data_dir / relative_path
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(extracted_file, dest_file)
|
||||
copied_count += 1
|
||||
|
||||
logger.info(f"Copied {copied_count} decompressed files to {data_dir}")
|
||||
|
||||
# Create marker file to indicate completion
|
||||
marker_file = self.game_root / ".jackify_bsa_decompressed"
|
||||
marker_file.touch()
|
||||
logger.info("BSA decompression completed successfully")
|
||||
return True, "BSA decompression completed successfully"
|
||||
else:
|
||||
# No files extracted - might be already decompressed or failed silently
|
||||
logger.warning("BSA decompressor returned 0 but no files were extracted")
|
||||
# Check if already decompressed by looking for marker
|
||||
marker_file = self.game_root / ".jackify_bsa_decompressed"
|
||||
if marker_file.exists():
|
||||
logger.info("BSA files already decompressed (marker file exists)")
|
||||
return True, "BSA files already decompressed"
|
||||
else:
|
||||
return False, "BSA decompressor completed but no files were extracted"
|
||||
else:
|
||||
# Exit code 1 often means "already decompressed" - check output and marker
|
||||
marker_file = self.game_root / ".jackify_bsa_decompressed"
|
||||
|
||||
# If output explicitly said "already modified/decompressed", treat as success
|
||||
if already_modified_detected:
|
||||
logger.info("BSA decompressor reports files already modified - marking as completed")
|
||||
marker_file.touch()
|
||||
return True, "BSA files already decompressed"
|
||||
|
||||
# Check marker file
|
||||
if marker_file.exists():
|
||||
logger.info("BSA decompressor returned error but marker file exists - assuming already completed")
|
||||
return True, "BSA decompression already completed"
|
||||
|
||||
# Try to provide helpful error message based on exit code and output
|
||||
logger.error(f"BSA decompressor failed with exit code {return_code}")
|
||||
|
||||
error_details = f"BSA decompressor failed with exit code {return_code}."
|
||||
|
||||
if return_code == 1:
|
||||
error_details += (
|
||||
"\n\nThis may indicate the BSA files are already decompressed or modified. "
|
||||
"If you've run this before, the step may have already completed. "
|
||||
"Otherwise, try running the decompressor manually from: "
|
||||
"https://www.nexusmods.com/newvegas/mods/65854"
|
||||
)
|
||||
else:
|
||||
error_details += (
|
||||
f"\n\nPlease check that:\n"
|
||||
f"1. Fallout New Vegas is properly installed at: {self.game_root}\n"
|
||||
f"2. The BSA files exist in the Data directory\n"
|
||||
f"3. You have write permissions to the game directory\n\n"
|
||||
f"You can complete this step manually using the guide at:\n"
|
||||
f"https://vivanewvegas.moddinglinked.com/wabbajack.html"
|
||||
)
|
||||
|
||||
return False, error_details
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "BSA decompression timed out after 10 minutes"
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to run BSA decompressor: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return False, error_msg
|
||||
@@ -40,24 +40,26 @@ class SuccessDialog(QDialog):
|
||||
self.setWindowTitle("Success!")
|
||||
self.setWindowModality(Qt.NonModal)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose, True)
|
||||
self.setFixedSize(500, 500)
|
||||
self.setWindowFlag(Qt.WindowDoesNotAcceptFocus, True)
|
||||
self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }" )
|
||||
layout = QVBoxLayout(self)
|
||||
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 = QFrame(self)
|
||||
card.setObjectName("successCard")
|
||||
card.setFrameShape(QFrame.StyledPanel)
|
||||
card.setFrameShadow(QFrame.Raised)
|
||||
card.setFixedWidth(440)
|
||||
card.setMinimumHeight(380)
|
||||
# Increase card width and reduce margins to maximize text width for 800p screens
|
||||
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_layout = QVBoxLayout(card)
|
||||
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(
|
||||
"QFrame#successCard { "
|
||||
" background: #23272e; "
|
||||
@@ -81,29 +83,38 @@ class SuccessDialog(QDialog):
|
||||
card_layout.addWidget(title_label)
|
||||
|
||||
# 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>'
|
||||
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:
|
||||
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)
|
||||
# Center the success message within the wider card for all screen sizes
|
||||
message_label.setAlignment(Qt.AlignCenter)
|
||||
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(
|
||||
"QLabel { "
|
||||
" font-size: 15px; "
|
||||
" color: #e0e0e0; "
|
||||
" line-height: 1.3; "
|
||||
" margin-bottom: 6px; "
|
||||
" word-wrap: break-word; "
|
||||
" padding: 0px; "
|
||||
"}"
|
||||
)
|
||||
message_label.setTextFormat(Qt.RichText)
|
||||
# Ensure the label itself is centered in the card layout and uses full width
|
||||
card_layout.addWidget(message_label, alignment=Qt.AlignCenter)
|
||||
# Ensure the label uses full width of the card before wrapping
|
||||
card_layout.addWidget(message_label)
|
||||
|
||||
# Time taken
|
||||
time_label = QLabel(f"Completed in {self.time_taken}")
|
||||
@@ -123,7 +134,7 @@ class SuccessDialog(QDialog):
|
||||
next_steps_label = QLabel(next_steps_text)
|
||||
next_steps_label.setAlignment(Qt.AlignCenter)
|
||||
next_steps_label.setWordWrap(True)
|
||||
next_steps_label.setMinimumHeight(100)
|
||||
# Remove fixed minimum height to allow natural sizing
|
||||
next_steps_label.setStyleSheet(
|
||||
"QLabel { "
|
||||
" font-size: 13px; "
|
||||
@@ -174,7 +185,7 @@ class SuccessDialog(QDialog):
|
||||
self._update_countdown()
|
||||
self._timer.start(1000)
|
||||
self.return_btn.clicked.connect(self.accept)
|
||||
self.exit_btn.clicked.connect(QApplication.quit)
|
||||
self.exit_btn.clicked.connect(self._safe_exit)
|
||||
|
||||
# Set the Wabbajack icon if available
|
||||
self._set_dialog_icon()
|
||||
@@ -247,3 +258,14 @@ class SuccessDialog(QDialog):
|
||||
self.return_btn.setEnabled(True)
|
||||
self.exit_btn.setEnabled(True)
|
||||
self._timer.stop()
|
||||
|
||||
def _safe_exit(self):
|
||||
"""Safely exit the application with proper cleanup"""
|
||||
try:
|
||||
if self._timer.isActive():
|
||||
self._timer.stop()
|
||||
self.close()
|
||||
QApplication.quit()
|
||||
except Exception as e:
|
||||
logger.error(f"Error during safe exit: {e}")
|
||||
QApplication.quit()
|
||||
112
jackify/frontends/gui/dialogs/vnv_automation_dialog.py
Normal file
112
jackify/frontends/gui/dialogs/vnv_automation_dialog.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
VNV Automation Confirmation Dialog
|
||||
|
||||
Custom dialog for VNV automation confirmation with optional BSA decompression checkbox.
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QCheckBox, QFrame, QTextEdit, QScrollArea
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
|
||||
class VNVAutomationDialog(QDialog):
|
||||
"""Dialog for confirming VNV automation with optional BSA decompression."""
|
||||
|
||||
def __init__(self, parent=None, description: str = ""):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("VNV Post-Install Automation")
|
||||
self.setModal(True)
|
||||
self.setFixedSize(600, 450)
|
||||
self.setStyleSheet("QDialog { background: #181818; color: #fff; }")
|
||||
|
||||
# Result: (confirmed: bool, include_bsa: bool)
|
||||
self.result_data = (False, True)
|
||||
|
||||
self.setup_ui(description)
|
||||
|
||||
def setup_ui(self, description: str):
|
||||
"""Set up the dialog UI."""
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setSpacing(0)
|
||||
main_layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
# Card background for content
|
||||
card = QFrame(self)
|
||||
card.setObjectName("vnvCard")
|
||||
card.setFrameShape(QFrame.StyledPanel)
|
||||
card.setFrameShadow(QFrame.Raised)
|
||||
card.setStyleSheet(
|
||||
"QFrame#vnvCard { "
|
||||
" background: #2d2d2d; "
|
||||
" border-radius: 12px; "
|
||||
" border: 1px solid #555; "
|
||||
"}"
|
||||
)
|
||||
card_layout = QVBoxLayout(card)
|
||||
card_layout.setSpacing(16)
|
||||
card_layout.setContentsMargins(28, 28, 28, 28)
|
||||
|
||||
# Description text - use QTextEdit for scrollable long text
|
||||
description_text = QTextEdit()
|
||||
description_text.setPlainText(description)
|
||||
description_text.setReadOnly(True)
|
||||
description_text.setMaximumHeight(200)
|
||||
description_text.setStyleSheet(
|
||||
"QTextEdit { "
|
||||
" background: #1a1a1a; "
|
||||
" color: #fff; "
|
||||
" border: 1px solid #555; "
|
||||
" border-radius: 4px; "
|
||||
" padding: 8px; "
|
||||
"}"
|
||||
)
|
||||
card_layout.addWidget(description_text)
|
||||
|
||||
# BSA Decompression checkbox
|
||||
self.bsa_checkbox = QCheckBox("Include BSA Decompression")
|
||||
self.bsa_checkbox.setChecked(True) # Default to checked
|
||||
self.bsa_checkbox.setStyleSheet("color: #fff; padding: 5px;")
|
||||
card_layout.addWidget(self.bsa_checkbox)
|
||||
|
||||
card_layout.addStretch()
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch()
|
||||
|
||||
self.yes_button = QPushButton("Yes")
|
||||
self.yes_button.setDefault(True)
|
||||
self.yes_button.setMinimumWidth(100)
|
||||
self.yes_button.clicked.connect(self.accept_dialog)
|
||||
button_layout.addWidget(self.yes_button)
|
||||
|
||||
self.no_button = QPushButton("No")
|
||||
self.no_button.setMinimumWidth(100)
|
||||
self.no_button.clicked.connect(self.reject_dialog)
|
||||
button_layout.addWidget(self.no_button)
|
||||
|
||||
card_layout.addLayout(button_layout)
|
||||
main_layout.addWidget(card)
|
||||
|
||||
def accept_dialog(self):
|
||||
"""Handle Yes button click."""
|
||||
self.result_data = (True, self.bsa_checkbox.isChecked())
|
||||
self.accept()
|
||||
|
||||
def reject_dialog(self):
|
||||
"""Handle No button click."""
|
||||
self.result_data = (False, False)
|
||||
self.reject()
|
||||
|
||||
def get_result(self) -> tuple[bool, bool]:
|
||||
"""
|
||||
Get the dialog result.
|
||||
|
||||
Returns:
|
||||
Tuple of (confirmed: bool, include_bsa_decompression: bool)
|
||||
"""
|
||||
return self.result_data
|
||||
|
||||
@@ -1495,7 +1495,8 @@ class JackifyMainWindow(QMainWindow):
|
||||
4: "Install Modlist Screen",
|
||||
5: "Install TTW Screen",
|
||||
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})")
|
||||
widget = self.stacked_widget.widget(index)
|
||||
@@ -1627,6 +1628,19 @@ class JackifyMainWindow(QMainWindow):
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes before closing"""
|
||||
try:
|
||||
# Clean up background threads first
|
||||
if hasattr(self, '_update_thread') and self._update_thread is not None:
|
||||
if self._update_thread.isRunning():
|
||||
self._update_thread.quit()
|
||||
self._update_thread.wait(2000)
|
||||
self._update_thread = None
|
||||
|
||||
if hasattr(self, '_gallery_cache_preload_thread') and self._gallery_cache_preload_thread is not None:
|
||||
if self._gallery_cache_preload_thread.isRunning():
|
||||
self._gallery_cache_preload_thread.quit()
|
||||
self._gallery_cache_preload_thread.wait(2000)
|
||||
self._gallery_cache_preload_thread = None
|
||||
|
||||
# Clean up GUI services
|
||||
for service in self.gui_services.values():
|
||||
if hasattr(service, 'cleanup'):
|
||||
@@ -1919,7 +1933,6 @@ def main():
|
||||
debug_mode = True
|
||||
# Temporarily save CLI debug flag to config so engine can see it
|
||||
config_handler.set('debug_mode', True)
|
||||
print("[DEBUG] CLI --debug flag detected, saved debug_mode=True to config")
|
||||
import logging
|
||||
|
||||
# Initialize file logging on root logger so all modules inherit it
|
||||
@@ -1928,14 +1941,23 @@ def main():
|
||||
# Only rotate log file when debug mode is enabled
|
||||
if debug_mode:
|
||||
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:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
print("[Jackify] Debug mode enabled (from config or CLI)")
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
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:
|
||||
root_logger.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
|
||||
|
||||
# Launch GUI application
|
||||
@@ -1990,7 +2012,7 @@ def main():
|
||||
icon = QIcon(path)
|
||||
if not icon.isNull():
|
||||
if debug_mode:
|
||||
print(f"[DEBUG] Using AppImage icon: {path}")
|
||||
logging.getLogger().debug(f"Using AppImage icon: {path}")
|
||||
break
|
||||
|
||||
# Priority 3: Fallback to any PNG in assets directory
|
||||
@@ -2001,8 +2023,8 @@ def main():
|
||||
icon = QIcon(try_path)
|
||||
|
||||
if debug_mode:
|
||||
print(f"[DEBUG] Final icon path: {icon_path}")
|
||||
print(f"[DEBUG] Icon is null: {icon.isNull()}")
|
||||
logging.getLogger().debug(f"Final icon path: {icon_path}")
|
||||
logging.getLogger().debug(f"Icon is null: {icon.isNull()}")
|
||||
|
||||
app.setWindowIcon(icon)
|
||||
window = JackifyMainWindow(dev_mode=dev_mode)
|
||||
|
||||
@@ -635,6 +635,9 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
# This ensures Proton version and winetricks settings are current
|
||||
self.config_handler._load_config()
|
||||
|
||||
# Store install_dir for later use in on_configuration_complete
|
||||
self._current_install_dir = install_dir
|
||||
|
||||
try:
|
||||
# Start time tracking
|
||||
self._workflow_start_time = time.time()
|
||||
@@ -648,7 +651,7 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
|
||||
class ConfigurationThread(QThread):
|
||||
progress_update = Signal(str)
|
||||
configuration_complete = Signal(bool, str, str)
|
||||
configuration_complete = Signal(bool, str, str, bool)
|
||||
error_occurred = Signal(str)
|
||||
|
||||
def __init__(self, modlist_name, install_dir, resolution):
|
||||
@@ -691,8 +694,8 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
def progress_callback(message):
|
||||
self.progress_update.emit(message)
|
||||
|
||||
def completion_callback(success, message, modlist_name):
|
||||
self.configuration_complete.emit(success, message, modlist_name)
|
||||
def completion_callback(success, message, modlist_name, enb_detected=False):
|
||||
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# Existing modlists shouldn't need manual steps, but handle gracefully
|
||||
@@ -729,12 +732,17 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
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")
|
||||
|
||||
def on_configuration_complete(self, success, message, modlist_name):
|
||||
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
|
||||
"""Handle configuration completion"""
|
||||
# Re-enable all controls when workflow completes
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
if success:
|
||||
# Check for VNV post-install automation after configuration
|
||||
install_dir = getattr(self, '_current_install_dir', None)
|
||||
if install_dir:
|
||||
self._check_and_run_vnv_automation(modlist_name, install_dir)
|
||||
|
||||
# Calculate time taken
|
||||
time_taken = self._calculate_time_taken()
|
||||
|
||||
@@ -763,6 +771,90 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
self._safe_append_text(f"Configuration error: {error_message}")
|
||||
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium")
|
||||
|
||||
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str):
|
||||
"""Check if VNV automation should run and execute if applicable
|
||||
|
||||
Args:
|
||||
modlist_name: Name of the installed modlist
|
||||
install_dir: Installation directory path
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
|
||||
# Get paths first (needed for VNV detection)
|
||||
install_path = Path(install_dir)
|
||||
|
||||
# Quick check before importing more (pass install location for ModOrganizer.ini check)
|
||||
if not should_offer_vnv_automation(modlist_name, install_path):
|
||||
return
|
||||
game_paths = PathHandler().find_vanilla_game_paths()
|
||||
game_root = game_paths.get('Fallout New Vegas')
|
||||
|
||||
if not game_root:
|
||||
debug_print("DEBUG: VNV automation skipped - FNV game root not found")
|
||||
return
|
||||
|
||||
# Confirmation callback - show dialog to user
|
||||
def confirmation_callback(description: str) -> bool:
|
||||
from ..services.message_service import MessageService
|
||||
reply = MessageService.question(
|
||||
self,
|
||||
"VNV Post-Install Automation",
|
||||
description,
|
||||
critical=False,
|
||||
safety_level="medium"
|
||||
)
|
||||
return reply == QMessageBox.Yes
|
||||
|
||||
# Manual file callback for non-Premium users
|
||||
def manual_file_callback(title: str, instructions: str) -> Optional[Path]:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
from ..services.message_service import MessageService
|
||||
|
||||
# Show instructions
|
||||
MessageService.information(self, title, instructions)
|
||||
|
||||
# Open file picker
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
title,
|
||||
str(Path.home() / "Downloads"),
|
||||
"All Files (*.*)"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
return Path(file_path)
|
||||
return None
|
||||
|
||||
# Run automation
|
||||
automation_ran, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=modlist_name,
|
||||
modlist_install_location=install_path,
|
||||
game_root=game_root,
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
progress_callback=None, # GUI doesn't need progress updates for post-install
|
||||
manual_file_callback=manual_file_callback,
|
||||
confirmation_callback=confirmation_callback
|
||||
)
|
||||
|
||||
if error:
|
||||
from ..services.message_service import MessageService
|
||||
MessageService.warning(
|
||||
self,
|
||||
"VNV Automation Failed",
|
||||
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
|
||||
"You can complete these steps manually by following the guide at:\n"
|
||||
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"ERROR: Failed to run VNV automation: {e}")
|
||||
import traceback
|
||||
debug_print(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
def show_manual_steps_dialog(self, extra_warning=""):
|
||||
modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist"
|
||||
msg = (
|
||||
|
||||
@@ -1464,6 +1464,11 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
if success:
|
||||
# Check for VNV post-install automation after configuration
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
if install_dir:
|
||||
self._check_and_run_vnv_automation(modlist_name, install_dir)
|
||||
|
||||
# Calculate time taken
|
||||
time_taken = self._calculate_time_taken()
|
||||
|
||||
@@ -1541,6 +1546,90 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
else:
|
||||
return f"{elapsed_seconds_remainder} seconds"
|
||||
|
||||
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str):
|
||||
"""Check if VNV automation should run and execute if applicable
|
||||
|
||||
Args:
|
||||
modlist_name: Name of the installed modlist
|
||||
install_dir: Installation directory path
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
|
||||
# Get paths first (needed for VNV detection)
|
||||
install_path = Path(install_dir)
|
||||
|
||||
# Quick check before importing more (pass install location for ModOrganizer.ini check)
|
||||
if not should_offer_vnv_automation(modlist_name, install_path):
|
||||
return
|
||||
game_paths = PathHandler().find_vanilla_game_paths()
|
||||
game_root = game_paths.get('Fallout New Vegas')
|
||||
|
||||
if not game_root:
|
||||
debug_print("DEBUG: VNV automation skipped - FNV game root not found")
|
||||
return
|
||||
|
||||
# Confirmation callback - show dialog to user
|
||||
def confirmation_callback(description: str) -> bool:
|
||||
from ..services.message_service import MessageService
|
||||
reply = MessageService.question(
|
||||
self,
|
||||
"VNV Post-Install Automation",
|
||||
description,
|
||||
critical=False,
|
||||
safety_level="medium"
|
||||
)
|
||||
return reply == QMessageBox.Yes
|
||||
|
||||
# Manual file callback for non-Premium users
|
||||
def manual_file_callback(title: str, instructions: str) -> Optional[Path]:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
from ..services.message_service import MessageService
|
||||
|
||||
# Show instructions
|
||||
MessageService.information(self, title, instructions)
|
||||
|
||||
# Open file picker
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
title,
|
||||
str(Path.home() / "Downloads"),
|
||||
"All Files (*.*)"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
return Path(file_path)
|
||||
return None
|
||||
|
||||
# Run automation
|
||||
automation_ran, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=modlist_name,
|
||||
modlist_install_location=install_path,
|
||||
game_root=game_root,
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
progress_callback=None, # GUI doesn't need progress updates for post-install
|
||||
manual_file_callback=manual_file_callback,
|
||||
confirmation_callback=confirmation_callback
|
||||
)
|
||||
|
||||
if error:
|
||||
from ..services.message_service import MessageService
|
||||
MessageService.warning(
|
||||
self,
|
||||
"VNV Automation Failed",
|
||||
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
|
||||
"You can complete these steps manually by following the guide at:\n"
|
||||
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"ERROR: Failed to run VNV automation: {e}")
|
||||
import traceback
|
||||
debug_print(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
def show_next_steps_dialog(self, message):
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("Next Steps")
|
||||
|
||||
@@ -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.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
|
||||
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)
|
||||
from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog
|
||||
|
||||
@@ -1784,6 +1784,27 @@ class InstallModlistScreen(QWidget):
|
||||
modlist_name = getattr(self, '_ttw_modlist_name', 'Unknown')
|
||||
game_name = "Fallout New Vegas"
|
||||
|
||||
# Check for VNV post-install automation after TTW installation
|
||||
vnv_automation_running = False
|
||||
if hasattr(self, '_ttw_install_dir') and hasattr(self, '_ttw_modlist_name'):
|
||||
vnv_automation_running = self._check_and_run_vnv_automation(self._ttw_modlist_name, self._ttw_install_dir)
|
||||
|
||||
if vnv_automation_running:
|
||||
# Store success dialog params for later (after VNV automation completes)
|
||||
self._pending_success_dialog_params = {
|
||||
'modlist_name': modlist_name,
|
||||
'time_taken': time_str,
|
||||
'game_name': game_name,
|
||||
'enb_detected': False, # TTW installs don't have ENB
|
||||
'ttw_version': ttw_version if 'ttw_version' in locals() else None
|
||||
}
|
||||
# Keep post-install feedback active during VNV automation
|
||||
# Don't show success dialog yet - will be shown in _on_vnv_complete
|
||||
return
|
||||
|
||||
# No VNV automation - end post-install feedback now
|
||||
self._end_post_install_feedback(True)
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
@@ -1797,7 +1818,7 @@ class InstallModlistScreen(QWidget):
|
||||
)
|
||||
|
||||
# Add TTW installation info to dialog if possible
|
||||
if hasattr(success_dialog, 'add_info_line'):
|
||||
if 'ttw_version' in locals() and hasattr(success_dialog, 'add_info_line'):
|
||||
success_dialog.add_info_line(f"TTW {ttw_version} integrated successfully")
|
||||
|
||||
success_dialog.show()
|
||||
@@ -1810,6 +1831,216 @@ class InstallModlistScreen(QWidget):
|
||||
f"TTW integration completed but failed to show success dialog: {str(e)}"
|
||||
)
|
||||
|
||||
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool:
|
||||
"""Check if VNV automation should run and execute if applicable in background thread
|
||||
|
||||
Args:
|
||||
modlist_name: Name of the installed modlist
|
||||
install_dir: Installation directory path
|
||||
|
||||
Returns:
|
||||
True if VNV automation is starting (success dialog should be deferred)
|
||||
False if no VNV automation needed (show success dialog immediately)
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
from jackify.backend.services.vnv_integration_helper import should_offer_vnv_automation
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
|
||||
# Get paths first (needed for VNV detection)
|
||||
install_path = Path(install_dir)
|
||||
|
||||
# Quick check before importing more (pass install location for ModOrganizer.ini check)
|
||||
if not should_offer_vnv_automation(modlist_name, install_path):
|
||||
return False
|
||||
|
||||
game_paths = PathHandler().find_vanilla_game_paths()
|
||||
game_root = game_paths.get('Fallout New Vegas')
|
||||
|
||||
if not game_root:
|
||||
debug_print("DEBUG: VNV automation skipped - FNV game root not found")
|
||||
return False
|
||||
|
||||
# Initialize service to check completion status
|
||||
vnv_service = VNVPostInstallService(
|
||||
modlist_install_location=install_path,
|
||||
game_root=game_root,
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path()
|
||||
)
|
||||
|
||||
# Check what's already done
|
||||
completed = vnv_service.check_already_completed()
|
||||
# Only skip if ALL three steps are completed
|
||||
if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed']:
|
||||
logger.info("VNV automation steps already completed")
|
||||
return False
|
||||
|
||||
# Get automation description for confirmation
|
||||
description = vnv_service.get_automation_description()
|
||||
|
||||
# Show confirmation dialog ON MAIN THREAD (not in worker thread!)
|
||||
from ..services.message_service import MessageService
|
||||
reply = MessageService.question(
|
||||
self,
|
||||
"VNV Post-Install Automation",
|
||||
description,
|
||||
critical=False,
|
||||
safety_level="medium"
|
||||
)
|
||||
|
||||
if reply != QMessageBox.Yes:
|
||||
logger.info("User declined VNV automation")
|
||||
return False
|
||||
|
||||
# Manual file callback for non-Premium users
|
||||
def manual_file_callback(title: str, instructions: str) -> Optional[Path]:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
from ..services.message_service import MessageService
|
||||
|
||||
# Show instructions
|
||||
MessageService.information(self, title, instructions)
|
||||
|
||||
# Open file picker
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
title,
|
||||
str(Path.home() / "Downloads"),
|
||||
"All Files (*.*)"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
return Path(file_path)
|
||||
return None
|
||||
|
||||
# Enable post-install progress tracking for VNV automation
|
||||
self._begin_post_install_feedback()
|
||||
|
||||
# User confirmed - start automation in background thread
|
||||
self._run_vnv_automation_threaded(
|
||||
modlist_name,
|
||||
install_path,
|
||||
game_root,
|
||||
manual_file_callback
|
||||
)
|
||||
|
||||
return True # VNV automation is running, defer success dialog
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"ERROR: Failed to start VNV automation: {e}")
|
||||
import traceback
|
||||
debug_print(f"Traceback: {traceback.format_exc()}")
|
||||
return False # Error - show success dialog anyway
|
||||
|
||||
def _run_vnv_automation_threaded(self, modlist_name, install_path, game_root,
|
||||
manual_file_callback):
|
||||
"""Run VNV automation in a background thread with progress updates
|
||||
|
||||
Note: User confirmation should already be obtained before calling this method.
|
||||
"""
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
|
||||
class VNVAutomationWorker(QThread):
|
||||
progress_update = Signal(str)
|
||||
completed = Signal(bool, str) # (success, error_message)
|
||||
|
||||
def __init__(self, modlist_name, install_path, game_root, ttw_installer_path,
|
||||
manual_file_callback):
|
||||
super().__init__()
|
||||
self.modlist_name = modlist_name
|
||||
self.install_path = install_path
|
||||
self.game_root = game_root
|
||||
self.ttw_installer_path = ttw_installer_path
|
||||
self.manual_file_callback = manual_file_callback
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
# User already confirmed, pass lambda that always returns True
|
||||
automation_ran, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=self.modlist_name,
|
||||
modlist_install_location=self.install_path,
|
||||
game_root=self.game_root,
|
||||
ttw_installer_path=self.ttw_installer_path,
|
||||
progress_callback=self.progress_update.emit,
|
||||
manual_file_callback=self.manual_file_callback,
|
||||
confirmation_callback=lambda desc: True # Already confirmed on main thread
|
||||
)
|
||||
self.completed.emit(error is None, error or "")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.completed.emit(False, f"Exception: {str(e)}\n{traceback.format_exc()}")
|
||||
|
||||
# Create and start worker
|
||||
self.vnv_worker = VNVAutomationWorker(
|
||||
modlist_name,
|
||||
install_path,
|
||||
game_root,
|
||||
AutomatedPrefixService.get_ttw_installer_path(),
|
||||
manual_file_callback
|
||||
)
|
||||
|
||||
# Connect signals
|
||||
self.vnv_worker.progress_update.connect(self._on_vnv_progress)
|
||||
self.vnv_worker.completed.connect(self._on_vnv_complete)
|
||||
self.vnv_worker.finished.connect(self.vnv_worker.deleteLater)
|
||||
|
||||
# Start worker
|
||||
self.vnv_worker.start()
|
||||
|
||||
def _on_vnv_progress(self, message: str):
|
||||
"""Handle VNV automation progress updates"""
|
||||
self._safe_append_text(message)
|
||||
# Also update progress indicator, Activity window, and Details window
|
||||
self._handle_post_install_progress(message)
|
||||
|
||||
def _on_vnv_complete(self, success: bool, error: str):
|
||||
"""Handle VNV automation completion and show deferred success dialog"""
|
||||
# End post-install feedback now that VNV automation is complete
|
||||
self._end_post_install_feedback(True)
|
||||
|
||||
if not success and error:
|
||||
from ..services.message_service import MessageService
|
||||
MessageService.warning(
|
||||
self,
|
||||
"VNV Automation Failed",
|
||||
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
|
||||
"You can complete these steps manually by following the guide at:\n"
|
||||
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
|
||||
)
|
||||
elif success:
|
||||
self._safe_append_text("VNV post-install automation completed successfully")
|
||||
|
||||
# Show the deferred success dialog now that VNV automation is complete
|
||||
if hasattr(self, '_pending_success_dialog_params'):
|
||||
params = self._pending_success_dialog_params
|
||||
del self._pending_success_dialog_params # Clean up
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
# Show success dialog
|
||||
from ..dialogs import SuccessDialog
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=params['modlist_name'],
|
||||
workflow_type="install",
|
||||
time_taken=params['time_taken'],
|
||||
game_name=params['game_name'],
|
||||
parent=self
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
# Show ENB Proton dialog if ENB was detected
|
||||
if params.get('enb_detected'):
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
|
||||
enb_dialog.exec() # Modal dialog - blocks until user clicks OK
|
||||
except Exception as e:
|
||||
# Non-blocking: if dialog fails, just log and continue
|
||||
logger.warning(f"Failed to show ENB dialog: {e}")
|
||||
|
||||
|
||||
def validate_and_start_install(self):
|
||||
@@ -2718,7 +2949,6 @@ class InstallModlistScreen(QWidget):
|
||||
# Render loop handles smooth updates - just set target state
|
||||
|
||||
current_step = progress_state.phase_step
|
||||
from jackify.shared.progress_models import FileProgress, OperationType
|
||||
|
||||
display_items = []
|
||||
|
||||
@@ -3256,6 +3486,39 @@ class InstallModlistScreen(QWidget):
|
||||
"backup",
|
||||
],
|
||||
},
|
||||
{
|
||||
'id': 'vnv_root_mods',
|
||||
'label': "VNV: Copying root mods",
|
||||
'keywords': [
|
||||
"step 1/3: copying root mods",
|
||||
"copying root mods to game directory",
|
||||
"root mods:",
|
||||
],
|
||||
},
|
||||
{
|
||||
'id': 'vnv_4gb_patch',
|
||||
'label': "VNV: Applying 4GB patch",
|
||||
'keywords': [
|
||||
"step 2/3: downloading and running 4gb patcher",
|
||||
"downloading fnv4gb",
|
||||
"downloading:",
|
||||
"fetching file list",
|
||||
"running 4gb patcher",
|
||||
"4gb patcher:",
|
||||
],
|
||||
},
|
||||
{
|
||||
'id': 'vnv_bsa_decompress',
|
||||
'label': "VNV: Decompressing BSA files",
|
||||
'keywords': [
|
||||
"step 3/3: downloading and running bsa decompressor",
|
||||
"downloading:",
|
||||
"fetching file list",
|
||||
"running bsa decompressor",
|
||||
"decompressing bsa files:",
|
||||
"bsa decompression:",
|
||||
],
|
||||
},
|
||||
{
|
||||
'id': 'config_finalize',
|
||||
'label': "Finalising Jackify configuration",
|
||||
@@ -3263,7 +3526,8 @@ class InstallModlistScreen(QWidget):
|
||||
"configuration completed successfully",
|
||||
"configuration complete",
|
||||
"manual steps validation failed",
|
||||
"configuration failed"
|
||||
"configuration failed",
|
||||
"vnv post-install completed successfully"
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -3866,7 +4130,8 @@ class InstallModlistScreen(QWidget):
|
||||
self.file_progress_list.stop_cpu_tracking()
|
||||
# Re-enable controls now that installation/configuration is complete
|
||||
self._enable_controls_after_operation()
|
||||
self._end_post_install_feedback(success)
|
||||
# Don't end post-install feedback yet - may continue with VNV automation
|
||||
# Will be called in _on_vnv_complete or after VNV check
|
||||
|
||||
if success:
|
||||
# Check if we need to show Somnium guidance
|
||||
@@ -3918,6 +4183,24 @@ class InstallModlistScreen(QWidget):
|
||||
self._initiate_ttw_workflow(modlist_name, install_dir)
|
||||
return # Don't show success dialog yet, will show after TTW completes
|
||||
|
||||
# Check for VNV post-install automation after TTW check
|
||||
vnv_automation_running = self._check_and_run_vnv_automation(modlist_name, install_dir)
|
||||
|
||||
if vnv_automation_running:
|
||||
# Store success dialog params for later (after VNV automation completes)
|
||||
self._pending_success_dialog_params = {
|
||||
'modlist_name': modlist_name,
|
||||
'time_taken': time_str,
|
||||
'game_name': game_name,
|
||||
'enb_detected': enb_detected
|
||||
}
|
||||
# Keep post-install feedback active during VNV automation
|
||||
# Don't show success dialog yet - will be shown in _on_vnv_complete
|
||||
return
|
||||
|
||||
# No VNV automation - end post-install feedback now
|
||||
self._end_post_install_feedback(True)
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
@@ -3942,10 +4225,12 @@ class InstallModlistScreen(QWidget):
|
||||
logger.warning(f"Failed to show ENB dialog: {e}")
|
||||
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
|
||||
# Max retries reached - show failure message
|
||||
self._end_post_install_feedback(False)
|
||||
MessageService.critical(self, "Manual Steps Failed",
|
||||
"Manual steps validation failed after multiple attempts.")
|
||||
else:
|
||||
# Configuration failed for other reasons
|
||||
self._end_post_install_feedback(False)
|
||||
MessageService.critical(self, "Configuration Failed",
|
||||
"Post-install configuration failed. Please check the console output.")
|
||||
except Exception as e:
|
||||
|
||||
@@ -175,7 +175,7 @@ class InstallTTWScreen(QWidget):
|
||||
instruction_text = QLabel(
|
||||
"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> '
|
||||
"(requires a user account for mod.db)"
|
||||
"(requires a user account for ModPub)"
|
||||
)
|
||||
instruction_text.setWordWrap(True)
|
||||
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":
|
||||
self.stacked_widget.setCurrentIndex(6) # Configure New Modlist Screen
|
||||
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):
|
||||
"""Return to main menu"""
|
||||
|
||||
@@ -106,6 +106,77 @@ class OverallProgressIndicator(QWidget):
|
||||
if not display_text or display_text == "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)
|
||||
|
||||
# Update progress bar if enabled
|
||||
@@ -150,6 +221,23 @@ class OverallProgressIndicator(QWidget):
|
||||
tooltip_parts.append(f"Step: {progress.phase_progress_text}")
|
||||
if 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:
|
||||
tooltip_parts.append(f"Overall: {progress.overall_percent:.1f}%")
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import shutil
|
||||
class LoggingHandler:
|
||||
"""
|
||||
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).
|
||||
- Handles log rotation and log directory creation.
|
||||
Usage:
|
||||
@@ -61,8 +61,15 @@ class LoggingHandler:
|
||||
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)
|
||||
|
||||
def setup_logger(self, name: str, log_file: Optional[str] = None, is_general: bool = False) -> logging.Logger:
|
||||
"""Set up a logger with file and console handlers. Call rotate_log_for_logger before this if you want per-run rotation."""
|
||||
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.
|
||||
|
||||
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.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
@@ -75,20 +82,21 @@ class LoggingHandler:
|
||||
'%(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()
|
||||
|
||||
# Check if debug mode is enabled
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
if debug_mode:
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
else:
|
||||
console_handler.setLevel(logging.ERROR)
|
||||
except Exception:
|
||||
# Fallback to ERROR level if config can't be loaded
|
||||
if debug_mode is None:
|
||||
# Check if debug mode is enabled from config
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
except Exception:
|
||||
debug_mode = False
|
||||
|
||||
if debug_mode:
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
else:
|
||||
console_handler.setLevel(logging.ERROR)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
|
||||
@@ -98,8 +106,9 @@ class LoggingHandler:
|
||||
if log_file or is_general:
|
||||
file_path = self.log_dir / (log_file if log_file else "jackify-cli.log")
|
||||
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=100*1024*1024, backupCount=5
|
||||
)
|
||||
# File handler always accepts DEBUG - root logger level controls what gets through
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
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):
|
||||
@@ -107,7 +116,7 @@ class LoggingHandler:
|
||||
|
||||
return logger
|
||||
|
||||
def rotate_logs(self, max_bytes: int = 1024 * 1024, backup_count: int = 5) -> None:
|
||||
def rotate_logs(self, max_bytes: int = 100 * 1024 * 1024, backup_count: int = 5) -> None:
|
||||
"""Rotate log files based on size."""
|
||||
for log_file in self.get_log_files():
|
||||
try:
|
||||
@@ -203,5 +212,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)
|
||||
@@ -6,7 +6,7 @@ Used by both parser and GUI components.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Optional
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from enum import Enum
|
||||
import time
|
||||
|
||||
@@ -97,6 +97,11 @@ class InstallationProgress:
|
||||
texture_conversion_total: int = 0 # Total textures to convert
|
||||
bsa_building_current: int = 0 # Current BSA being built
|
||||
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):
|
||||
"""Ensure percent is in valid range."""
|
||||
@@ -122,6 +127,185 @@ class InstallationProgress:
|
||||
else:
|
||||
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:
|
||||
"""Get overall speed display from aggregate speeds reported by engine."""
|
||||
def _fresh_speed(op_key: str) -> float:
|
||||
@@ -313,3 +497,7 @@ class InstallationProgress:
|
||||
self.speed_timestamps[op_key] = time.time()
|
||||
self.timestamp = time.time()
|
||||
|
||||
# Update speed history for ETA smoothing
|
||||
if speed > 0:
|
||||
self._update_speed_history(op_key, speed)
|
||||
|
||||
|
||||
19
jackify/tools/.gitignore
vendored
Normal file
19
jackify/tools/.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Tools downloaded during AppImage build
|
||||
# These are fetched from upstream sources during build_appimage_simple.sh
|
||||
# They are NOT stored in git to keep the repository clean
|
||||
# See THIRD_PARTY_NOTICES.md for licensing information
|
||||
|
||||
# Downloaded during build:
|
||||
winetricks
|
||||
cabextract
|
||||
7z
|
||||
wget
|
||||
unzip
|
||||
sha256sum
|
||||
|
||||
# lz4 is copied from system during build
|
||||
lz4
|
||||
|
||||
# Keep this .gitignore and README
|
||||
!.gitignore
|
||||
!README.md
|
||||
BIN
jackify/tools/7z
BIN
jackify/tools/7z
Binary file not shown.
52
jackify/tools/README.md
Normal file
52
jackify/tools/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Jackify Tools Directory
|
||||
|
||||
This directory contains utility tools bundled with Jackify for self-contained AppImage distribution.
|
||||
|
||||
## How It Works
|
||||
|
||||
**In Git Repository**: This directory only contains this README and .gitignore (no binaries).
|
||||
|
||||
**During Build**: The `build_appimage_simple.sh` script downloads required tools from their official sources.
|
||||
|
||||
**In AppImage**: Tools are bundled for self-contained distribution (users don't need to install dependencies).
|
||||
|
||||
## Tools Downloaded During Build
|
||||
|
||||
### winetricks
|
||||
- **Source**: https://github.com/Winetricks/winetricks
|
||||
- **License**: LGPL v2.1
|
||||
- **Purpose**: Wine prefix configuration and Windows component installation
|
||||
- **Downloaded from**: GitHub master branch (latest version)
|
||||
|
||||
### cabextract
|
||||
- **Source**: https://github.com/kyz/cabextract
|
||||
- **License**: GPL v3
|
||||
- **Purpose**: Microsoft Cabinet file extraction
|
||||
- **Downloaded from**: GitHub releases (v1.11)
|
||||
|
||||
### 7-Zip (7z / 7zz)
|
||||
- **Source**: https://www.7-zip.org/
|
||||
- **License**: GNU LGPL + unRAR restriction
|
||||
- **Purpose**: Archive extraction and compression
|
||||
- **Downloaded from**: Official 7-Zip website
|
||||
|
||||
### lz4
|
||||
- **Source**: System package (copied from /usr/bin/lz4)
|
||||
- **License**: BSD 2-Clause
|
||||
- **Purpose**: Fast compression for TTW installer patch decompression
|
||||
- **Note**: Copied from build system, not downloaded
|
||||
|
||||
## Why Not Store in Git?
|
||||
|
||||
1. **Repository Size**: Binaries total ~11MB, bloating git history
|
||||
2. **Updates**: Tools update independently of Jackify code
|
||||
3. **Professional Practice**: Industry standard separates source code from build artifacts
|
||||
4. **Licensing**: Clear separation between our code and third-party binaries
|
||||
|
||||
## For Developers
|
||||
|
||||
When building locally with `./build_appimage_simple.sh`, these tools are automatically downloaded.
|
||||
|
||||
If build fails to download tools, the script will attempt to use system versions as fallback.
|
||||
|
||||
See `THIRD_PARTY_NOTICES.md` in project root for complete licensing information.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -76,6 +76,7 @@ WINETRICKS_VERSION=20250102-next
|
||||
# Copyright (C) 2013,2016 André Hentschel <nerv!dawncrow.de>
|
||||
# Copyright (C) 2023 Georgi Georgiev (RacerBG) <g.georgiev.shumen!gmail.com>
|
||||
# Copyright (C) 2025 ykla <yklaxds!gmail.com>
|
||||
# Copyright (C) 2025 Allan Rémy
|
||||
#
|
||||
# License:
|
||||
# This program is free software; you can redistribute it and/or
|
||||
@@ -360,6 +361,31 @@ _w_get_broken_messages()
|
||||
broken_only_bad_version_known_win64="Пакетът (${W_PACKAGE}) е повреден при 64-битовата архитектура на wine-${_wine_version_stripped}. Повреден е от версия ${bad_version}. Използвайте папка, създадена с WINEARCH=win32. Вижте ${bug_link} за повече информация. Използвайте --force, за да опитате въпреки това."
|
||||
broken_no_version_known_win64="Пакетът (${W_PACKAGE}) е повреден, когато wine е създаден без mingw. Вижте ${bug_link} за повече информация. Използвайте --force, за да опитате въпреки това."
|
||||
;;
|
||||
fr*)
|
||||
# default broken messages
|
||||
broken_good_version_known_default="Ce paquet (${W_PACKAGE}) ne fonctionne pas correctement dans wine-${_wine_version_stripped}. Effectuez une mise à niveau vers >=${good_version}. Consultez ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
broken_good_and_bad_version_known_default="Ce paquet (${W_PACKAGE}) est cassé dans wine-${_wine_version_stripped}. Cassé depuis ${bad_version}. Mettez à jour vers >=${good_version}. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
broken_only_bad_version_known_default="Ce paquet (${W_PACKAGE}) est cassé dans wine-${_wine_version_stripped}. Cassé depuis ${bad_version}. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
broken_no_version_known_default="Ce paquet (${W_PACKAGE}) est cassé. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
|
||||
# mingw broken messages
|
||||
broken_good_version_known_mingw="Ce paquet (${W_PACKAGE}) ne fonctionne pas sous wine-${_wine_version_stripped} lorsque wine est compilé avec mingw. Effectuez une mise à niveau vers >=${good_version} ou recompilez wine sans mingw. Consultez ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
broken_good_and_bad_version_known_mingw="Ce paquet (${W_PACKAGE}) est cassé dans wine-${_wine_version_stripped}. Cassé depuis ${bad_version} lorsque wine est compilé avec mingw. Mettez à jour vers >=${good_version} ou recompilez wine sans mingw. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
broken_only_bad_version_known_mingw="Ce paquet (${W_PACKAGE}) est cassé dans wine-${_wine_version_stripped}. Cassé depuis ${bad_version} lorsque wine est compilé avec mingw. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
broken_no_version_known_mingw="Ce paquet (${W_PACKAGE}) est cassé lorsque Wine est compilé avec mingw. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
|
||||
# no mingw broken messages
|
||||
broken_good_version_known_no_mingw="Ce paquet (${W_PACKAGE}) est défectueux dans wine-${_wine_version_stripped} lorsque wine est compilé sans mingw. Effectuez une mise à niveau vers >=${good_version}. Consultez ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
broken_good_and_bad_version_known_no_mingw="Ce paquet (${W_PACKAGE}) est cassé dans wine-${_wine_version_stripped}. Cassé depuis ${bad_version} lorsque wine est compilé sans mingw. Mettez à jour vers >=${good_version}. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
broken_only_bad_version_known_no_mingw="Ce paquet (${W_PACKAGE}) est cassé dans wine-${_wine_version_stripped}. Cassé depuis ${bad_version} lorsque wine est compilé sans mingw. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
broken_no_version_known_no_mingw="Ce paquet (${W_PACKAGE}) est cassé lorsque wine est compilé sans mingw. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
|
||||
# win64 broken messages
|
||||
broken_good_version_known_win64="Ce paquet (${W_PACKAGE}) ne fonctionne pas sous Wine 64 bits-${_wine_version_stripped}. Utilisez un préfixe créé avec WINEARCH=win32 ou mettez à niveau Wine vers >=${good_version} pour contourner ce problème. Ou utilisez --force pour essayer quand même. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
broken_good_and_bad_version_known_win64="Ce paquet (${W_PACKAGE}) est cassé sur wine-${_wine_version_stripped} 64 bits. Cassé depuis ${bad_version}. Utilisez un préfixe créé avec WINEARCH=win32 ou mettez à jour wine vers >=${good_version} pour contourner ce problème. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
broken_only_bad_version_known_win64="Ce paquet (${W_PACKAGE}) est cassé sur wine-${_wine_version_stripped} 64 bits. Cassé depuis ${bad_version}. Utilisez un préfixe créé avec WINEARCH=win32 pour contourner ce problème. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
broken_no_version_known_win64="Ce paquet (${W_PACKAGE}) est cassé lorsque wine est compilé sans mingw. Voir ${bug_link} pour plus d'informations. Utilisez --force pour essayer quand même."
|
||||
;;
|
||||
pt*)
|
||||
# default broken messages
|
||||
broken_good_version_known_default="O pacote (${W_PACKAGE}) está quebrado no wine-${_wine_version_stripped}. Atualize para >=${good_version}. Veja ${bug_link} para mais informações. Use --force para tentar forçar de toda forma."
|
||||
@@ -1154,6 +1180,29 @@ w_get_github_latest_prerelease()
|
||||
echo "${latest_version}"
|
||||
}
|
||||
|
||||
# Get the latest tagged release from gitlab.com API
|
||||
w_get_gitlab_latest_release()
|
||||
{
|
||||
# FIXME: can we get releases that aren't on master branch?
|
||||
org="$1"
|
||||
repo="$2"
|
||||
|
||||
# release.json might still exists from the previous verb
|
||||
w_try rm -f "${W_TMP_EARLY}/release.json"
|
||||
|
||||
WINETRICKS_SUPER_QUIET=1 w_download_to "${W_TMP_EARLY}" "https://gitlab.com/api/v4/projects/${org}%2F${repo}/releases" "" "release.json" >/dev/null 2>&1
|
||||
|
||||
# aria2c condenses the json (https://github.com/aria2/aria2/issues/1389)
|
||||
# but curl/wget don't, so handle both cases:
|
||||
json_length="$(wc -l "${W_TMP_EARLY}/release.json")"
|
||||
case "${json_length}" in
|
||||
0*) latest_version="$(sed -e "s/\",\"/|/g" "${W_TMP_EARLY}/release.json" | tr '|' '\n' | grep tag_name | sed 's@.*"@@' | head -n 1)";;
|
||||
*) latest_version="$(grep -w tag_name "${W_TMP_EARLY}/release.json" | cut -d '"' -f 4 | head -n 1)";;
|
||||
esac
|
||||
|
||||
echo "${latest_version}"
|
||||
}
|
||||
|
||||
# get sha256sum string and set $_W_gotsha256sum to it
|
||||
w_get_sha256sum()
|
||||
{
|
||||
@@ -3193,6 +3242,17 @@ winetricks_prefixmenu()
|
||||
_W_msg_unattended1="Включване на автоматичното инсталиране"
|
||||
_W_msg_help="Отваряне на помощта"
|
||||
;;
|
||||
fr*) _W_msg_title="Winetricks - Choisir un préfixe"
|
||||
_W_msg_body="Que souhaitez-vous faire ?"
|
||||
_W_msg_apps="Installer une application"
|
||||
_W_msg_benchmarks="Installer un benchmark"
|
||||
_W_msg_default="Sélectionner le préfixe par défaut"
|
||||
_W_msg_mkprefix="Créer un nouveau préfixe"
|
||||
_W_msg_unattended0="Désactiver une installation silencieuse"
|
||||
_W_msg_unattended1="Activer une installation silencieuse"
|
||||
_W_msg_help="Voir aide"
|
||||
_W_msg_cancel="Quitter"
|
||||
;;
|
||||
ru*) _W_msg_title="Winetricks - выберите путь wine (префикс)"
|
||||
_W_msg_body='Что вы хотите сделать?'
|
||||
_W_msg_apps='Установить программу'
|
||||
@@ -3373,6 +3433,10 @@ winetricks_mkprefixmenu()
|
||||
_W_msg_name="Name"
|
||||
_W_msg_arch="Architektur"
|
||||
;;
|
||||
fr*) _W_msg_title="Winetricks - Créer un nouveau préfixe"
|
||||
_W_msg_name="Nom :"
|
||||
_W_msg_arch="Architecture"
|
||||
;;
|
||||
pt*) _W_msg_title="Winetricks - criar novo wineprefix"
|
||||
_W_msg_name="Nome"
|
||||
_W_msg_arch="Arquitetura"
|
||||
@@ -3458,6 +3522,23 @@ winetricks_mainmenu()
|
||||
_W_msg_folder='Ordner durchsuchen'
|
||||
_W_msg_annihilate="ALLE DATEIEN UND PROGRAMME IN DIESEM WINEPREFIX Löschen"
|
||||
;;
|
||||
fr*) _W_msg_title="Winetricks - Le préfixe actuel est \"${WINEPREFIX}\""
|
||||
_W_msg_body='Que souhaitez-vous faire avec ce préfixe ?'
|
||||
_W_msg_cancel="Revenir en arrière"
|
||||
_W_msg_dlls="Installer une DLL ou un composant Windows"
|
||||
_W_msg_fonts='Installer une police'
|
||||
_W_msg_settings='Modifier les paramètres'
|
||||
_W_msg_winecfg='Exécuter winecfg'
|
||||
_W_msg_regedit='Exécuter regedit'
|
||||
_W_msg_taskmgr='Exécuter taskmgr'
|
||||
_W_msg_explorer='Exécuter explorer'
|
||||
_W_msg_uninstaller='Exécuter uninstaller'
|
||||
_W_msg_winecmd='Exécuter une invite de commande'
|
||||
_W_msg_wine_misc_exe='Exécuter un exécutable arbitraire (.exe/.msi/.msu)'
|
||||
_W_msg_shell='Exécuter une invite de commande pour le débogage'
|
||||
_W_msg_folder='Parcourir les fichiers'
|
||||
_W_msg_annihilate="Supprimer TOUTES LES DONNÉES ET APPLICATIONS CONTENUES DANS CE PRÉFIXE"
|
||||
;;
|
||||
pl*) _W_msg_title="Winetricks - obecny prefiks to \"${WINEPREFIX}\""
|
||||
_W_msg_body='Co chcesz zrobić w tym prefiksie?'
|
||||
_W_msg_dlls="Zainstalować windowsową bibliotekę DLL lub komponent"
|
||||
@@ -3472,7 +3553,7 @@ winetricks_mainmenu()
|
||||
_W_msg_wine_misc_exe='Run an arbitrary executable (.exe/.msi/.msu)'
|
||||
_W_msg_shell='Uruchomić powłokę wiersza poleceń (dla debugowania)'
|
||||
_W_msg_folder='Przeglądać pliki'
|
||||
_W_msg_annihilate="Usuńąć WSZYSTKIE DANE I APLIKACJE WEWNĄTRZ TEGO PREFIKSU WINE"
|
||||
_W_msg_annihilate="Usunąć WSZYSTKIE DANE I APLIKACJE WEWNĄTRZ TEGO PREFIKSU WINE"
|
||||
;;
|
||||
pt*) _W_msg_title="Winetricks - o prefixo atual é \"${WINEPREFIX}\""
|
||||
_W_msg_body='O que você gostaria de fazer com este prefixo wineprefix?'
|
||||
@@ -3644,6 +3725,9 @@ winetricks_settings_menu()
|
||||
de*) _W_msg_title="Winetricks - Aktueller Präfix ist \"${WINEPREFIX}\""
|
||||
_W_msg_body='Welche Einstellungen möchten Sie ändern?'
|
||||
;;
|
||||
fr*) _W_msg_title="Winetricks - Le préfixe actuel est \"${WINEPREFIX}\""
|
||||
_W_msg_body='Quels paramètres souhaitez-vous modifier ?'
|
||||
;;
|
||||
pl*) _W_msg_title="Winetricks - obecny prefiks to \"${WINEPREFIX}\""
|
||||
_W_msg_body='Jakie ustawienia chcesz zmienić?'
|
||||
;;
|
||||
@@ -3706,6 +3790,19 @@ winetricks_settings_menu()
|
||||
--width ${WINETRICKS_MENU_WIDTH} \
|
||||
"
|
||||
;;
|
||||
fr*) printf %s "zenity \
|
||||
--title '${_W_msg_title}' \
|
||||
--text '${_W_msg_body}' \
|
||||
--cancel-label 'Revenir en arrière' \
|
||||
--list \
|
||||
--checklist \
|
||||
--column '' \
|
||||
--column Paramètres \
|
||||
--column Titre \
|
||||
--height ${WINETRICKS_MENU_HEIGHT} \
|
||||
--width ${WINETRICKS_MENU_WIDTH} \
|
||||
"
|
||||
;;
|
||||
pl*) printf %s "zenity \
|
||||
--title '${_W_msg_title}' \
|
||||
--text '${_W_msg_body}' \
|
||||
@@ -3867,6 +3964,10 @@ winetricks_showmenu()
|
||||
_W_msg_body='Welche Paket(e) möchten Sie installieren?'
|
||||
_W_cached="gecached"
|
||||
;;
|
||||
fr*) _W_msg_title="Winetricks - Le préfixe actuel est \"${WINEPREFIX}\""
|
||||
_W_msg_body="Quel(s) paquet(s) souhaitez-vous installer ?"
|
||||
_W_cached="mis en cache"
|
||||
;;
|
||||
pl*) _W_msg_title="Winetricks - obecny prefiks to \"${WINEPREFIX}\""
|
||||
_W_msg_body='Które paczki chesz zainstalować?'
|
||||
_W_cached="zarchiwizowane"
|
||||
@@ -3949,6 +4050,22 @@ winetricks_showmenu()
|
||||
--width ${WINETRICKS_MENU_WIDTH} \
|
||||
"
|
||||
;;
|
||||
fr*) printf %s "zenity \
|
||||
--title '${_W_msg_title}' \
|
||||
--text '${_W_msg_body}' \
|
||||
--list \
|
||||
--checklist \
|
||||
--column '' \
|
||||
--column Package \
|
||||
--column Titre \
|
||||
--column Éditeur \
|
||||
--column Année \
|
||||
--column Media \
|
||||
--column Status \
|
||||
--height ${WINETRICKS_MENU_HEIGHT} \
|
||||
--width ${WINETRICKS_MENU_WIDTH} \
|
||||
"
|
||||
;;
|
||||
pl*) printf %s "zenity \
|
||||
--title '${_W_msg_title}' \
|
||||
--text '${_W_msg_body}' \
|
||||
@@ -4289,6 +4406,7 @@ winetricks_list_all()
|
||||
bg*) _W_cached="кеширано" ; _W_download="за изтегляне" ;;
|
||||
da*) _W_cached="cached" ; _W_download="kan hentes" ;;
|
||||
de*) _W_cached="gecached" ; _W_download="herunterladbar";;
|
||||
fr*) _W_cached="mis en cache" ; _W_download="téléchargeable";;
|
||||
pl*) _W_cached="zarchiwizowane" ; _W_download="do pobrania" ;;
|
||||
pt*) _W_cached="em cache" ; _W_download="para download" ;;
|
||||
ru*) _W_cached="в кэше" ; _W_download="доступно для скачивания" ;;
|
||||
@@ -4609,6 +4727,7 @@ winetricks_set_wineprefix()
|
||||
if [ "${W_NO_WIN64_WARNINGS}" = 0 ]; then
|
||||
case ${LANG} in
|
||||
bg*) w_warn "Използвате 64-битова папка. Повечето програми са за 32-битова архитектура. Ако възникнат проблеми, моля, използвайте 32-битова папка, преди да ги докладвате." ;;
|
||||
fr*) w_warn "Vous utilisez un WINEPREFIX 64 bits. Notez que de nombreux verbes n'installent que des versions 32 bits des paquets. Si vous rencontrez des problèmes, veuillez refaire un test dans un WINEPREFIX 32 bits propre avant de signaler un bug." ;;
|
||||
ru*) w_warn "Вы используете 64-битный WINEPREFIX. Важно: многие ветки устанавливают только 32-битные версии пакетов. Если у вас возникли проблемы, пожалуйста, проверьте еще раз на чистом 32-битном WINEPREFIX до отправки отчета об ошибке." ;;
|
||||
pt*) w_warn "Você está usando um WINEPREFIX de 64-bit. Observe que muitos casos instalam apenas versões de pacotes de 32-bit. Se você encontrar problemas, teste novamente em um WINEPREFIX limpo de 32-bit antes de relatar um bug." ;;
|
||||
zh_CN*) w_warn "您正在使用 64 位的 WINEPREFIX。请注意,许多脚本(verbs)只安装 32 位版本的软件包。如果遇到问题,请先在干净的 32 位 WINEPREFIX 中重新测试,然后再报告错误。" ;;
|
||||
@@ -4721,6 +4840,7 @@ winetricks_annihilate_wineprefix()
|
||||
|
||||
case ${LANG} in
|
||||
bg*) w_askpermission "Изтриване на ${WINEPREFIX}, нейните приложения, икони и менюта?" ;;
|
||||
fr*) w_askpermission "Supprimer ${WINEPREFIX}, ses applications, ses icônes et ses éléments de menu ?" ;;
|
||||
uk*) w_askpermission "Бажаєте видалити '${WINEPREFIX}'?" ;;
|
||||
pl*) w_askpermission "Czy na pewno chcesz usunąć prefiks ${WINEPREFIX} i wszystkie jego elementy?" ;;
|
||||
pt*) w_askpermission "Apagar ${WINEPREFIX}, Estes apps, ícones e ítens do menu?" ;;
|
||||
@@ -5082,6 +5202,50 @@ arch=32|64 Neues wineprefix mit 32 oder 64 bit erstellen, diese Optio
|
||||
nicht im Falle des Standard Wineprefix.
|
||||
prefix=foobar WINEPREFIX=${W_PREFIXES_ROOT}/foobar auswählen
|
||||
annihilate ALLE DATEIEN UND PROGRAMME IN DIESEM WINEPREFIX Löschen
|
||||
_EOF_
|
||||
;;
|
||||
fr*)
|
||||
cat <<_EOF_
|
||||
Utilisation : $0 [options] [commande|verbe|chemin-vers-le-verbe] ...
|
||||
Exécute les verbes donnés. Chaque verbe installe une application ou modifie un paramètre.
|
||||
|
||||
Options :
|
||||
--country=CC Définit le code pays sur CC et ne détecte pas votre adresse IP
|
||||
-f, --force Ne vérifie pas si les paquets sont déjà installés
|
||||
--gui Affiche les diagnostics de l'interface graphique même lorsque l'application est lancée en ligne de commande
|
||||
--gui=OPT Définit OPT sur kdialog ou zenity pour remplacer le moteur GUI
|
||||
--isolate Installe chaque application ou jeu dans son propre bottle (WINEPREFIX)
|
||||
--self-update Mettre à jour cette application vers la dernière version (non Debian)
|
||||
--update-rollback Annuler la dernière mise à jour automatique
|
||||
-k, --keep_isos Mettre en cache les images ISO (permet une installation ultérieure sans disque)
|
||||
--no-clean Ne pas supprimer les répertoires temporaires (utile pendant le débogage)
|
||||
--optin Activer le rapport des verbes que vous utilisez aux responsables de Winetricks
|
||||
--optout Désactiver le rapport des verbes que vous utilisez aux responsables de Winetricks
|
||||
-q, --unattended Ne poser aucune question, installer automatiquement
|
||||
-r, --ddrescue Réessayer plusieurs fois lors de la mise en cache de disques rayés
|
||||
-t --torify Exécute les téléchargements sous torify, si disponible
|
||||
--verify Exécute des tests GUI (automatisés) pour les verbes, si disponibles
|
||||
-v, --verbose Affiche toutes les commandes au fur et à mesure de leur exécution
|
||||
-h, --help Affiche ce message et quitte
|
||||
-V, --version Affiche la version et quitte
|
||||
|
||||
Commandes :
|
||||
list Liste des catégories
|
||||
list-all Liste toutes les catégories et leurs verbes
|
||||
apps list Liste les verbes dans la catégorie « applications »
|
||||
benchmarks list Liste les verbes dans la catégorie « benchmarks »
|
||||
dlls list Liste les verbes de la catégorie « dlls »
|
||||
fonts list Liste les verbes de la catégorie « polices »
|
||||
settings list Liste les verbes de la catégorie « paramètres »
|
||||
list-cached Liste les verbes mis en cache et prêts à être installés
|
||||
list-download Liste les verbes qui se téléchargent automatiquement
|
||||
list-manual-download Liste les verbes qui se téléchargent avec l'aide de l'utilisateur
|
||||
list-installed Liste les verbes déjà installés
|
||||
arch=32|64 Crée un préfixe en 32 ou 64 bits, cette option doit être
|
||||
Spécifiée avant prefix=foobar et ne fonctionnera pas dans le cas du
|
||||
wineprefix par défaut.
|
||||
prefix=foobar Sélectionner WINEPREFIX=${W_PREFIXES_ROOT}/foobar
|
||||
annihilate Supprimer TOUTES LES DONNÉES ET APPLICATIONS CONTENUES DANS CE WINEPREFIX
|
||||
_EOF_
|
||||
;;
|
||||
zh_CN*)
|
||||
@@ -5122,6 +5286,7 @@ list-installed 列出已安装的脚本
|
||||
arch=32|64 创建 32 位或 64 位 wineprefix,此选项必须在 prefix=foobar 之前指定,默认 wineprefix 不支持此选项
|
||||
prefix=foobar 选择 WINEPREFIX 为 ${W_PREFIXES_ROOT}/foobar
|
||||
annihilate 删除该 WINEPREFIX 内的所有数据和应用程序
|
||||
_EOF_
|
||||
;;
|
||||
*)
|
||||
cat <<_EOF_
|
||||
@@ -6452,7 +6617,7 @@ load_dinput8()
|
||||
#----------------------------------------------------------------
|
||||
|
||||
w_metadata dinputto8 dlls \
|
||||
title="A dll module that is designed to improve compatibility in games using DirectInput 1-7 by converting all API calls to their equivalent DirectInput 8 (1.0.78.0)" \
|
||||
title="A dll module that is designed to improve compatibility in games using DirectInput 1-7 by converting all API calls to their equivalent DirectInput 8 (1.0.92.0)" \
|
||||
homepage="https://github.com/elishacloud/dinputto8" \
|
||||
publisher="Elisha Riedlinger" \
|
||||
year="2018" \
|
||||
@@ -6463,7 +6628,7 @@ w_metadata dinputto8 dlls \
|
||||
|
||||
load_dinputto8()
|
||||
{
|
||||
w_download https://github.com/elishacloud/dinputto8/releases/download/v1.0.78.0/dinput.dll 467f50cac676635ed68658b8be32e1a2cacece37a22bb13e8ec8330706a32ca7
|
||||
w_download https://github.com/elishacloud/dinputto8/releases/download/v1.0.92.0/dinput.dll 8f1e53a55c66f870b91c1e43a39c02a4a87900cf453776de41643863bdfa00e6
|
||||
w_try_cp_dll "${W_CACHE}/${W_PACKAGE}/dinput.dll" "${W_SYSTEM32_DLLS}/dinput.dll"
|
||||
w_override_dlls native dinput
|
||||
}
|
||||
@@ -7841,6 +8006,86 @@ load_dxvk()
|
||||
|
||||
#----------------------------------------------------------------
|
||||
|
||||
# $1 - dxvk async archive name (required)
|
||||
# $2 - dxvk async version (required)
|
||||
# $3 - minimum Wine version (required)
|
||||
# $4 - minimum Vulkan API version (required)
|
||||
# $5 - [dxgi,][d3d8,][d3d9,][d3d10core,]d3d11 (required)
|
||||
helper_dxvk_async()
|
||||
{
|
||||
_W_package_archive="${1}"
|
||||
_W_package_version="${2}"
|
||||
_W_min_wine_version="${3}"
|
||||
_W_min_vulkan_version="${4}"
|
||||
_W_dll_overrides="$(echo "${5}" | sed 's/,/ /g')"
|
||||
# dxvk async repository, for d3d8/d3d9/d3d10/d3d11 support
|
||||
_W_repository="Ph42oN/dxvk-gplasync"
|
||||
|
||||
_W_supported_overrides="dxgi d3d8 d3d9 d3d10core d3d11"
|
||||
_W_invalid_overrides="$(echo "${_W_dll_overrides}" | awk -vvalid_overrides_regex="$(echo "${_W_supported_overrides}" | sed 's/ /|/g')" '{ gsub(valid_overrides_regex,""); sub("[ ]*",""); print $0 }')"
|
||||
if [ "${_W_invalid_overrides}" != "" ]; then
|
||||
w_die "parameter (4) unsupported dll override: '${_W_invalid_overrides}' ; supported dll overrides: ${_W_supported_overrides}"
|
||||
fi
|
||||
|
||||
_W_package_dir="${_W_package_archive%.tar.gz}"
|
||||
w_warn "Please refer to ${_W_repository#*/} version ${_W_package_version} release notes... See: https://gitlab.com/${_W_repository}/-/releases/v${_W_package_version}"
|
||||
w_warn "Please refer to current dxvk base graphics driver requirements... See: https://github.com/doitsujin/dxvk/wiki/Driver-support" # The async fork lacks a wiki, and upstream's is most relevant anyway
|
||||
|
||||
if w_wine_version_in ",${_W_min_wine_version}" ; then
|
||||
# shellcheck disable=SC2140
|
||||
w_warn "${_W_repository#*/} ${_W_package_version} does not support wine version ${_wine_version_stripped} . "\
|
||||
"${_W_repository#*/} ${_W_package_version} requires wine version ${_W_min_wine_version} (or newer). "\
|
||||
"Vulkan ${_W_min_vulkan_version} API (or newer) support is recommended."
|
||||
fi
|
||||
|
||||
if [ "${_W_package_archive##*.}" = "zip" ]; then
|
||||
w_try_unzip "${W_TMP}" "${W_CACHE}/${W_PACKAGE}/${_W_package_archive}"
|
||||
else
|
||||
w_try tar -C "${W_TMP}" -zxf "${W_CACHE}/${W_PACKAGE}/${_W_package_archive}"
|
||||
fi
|
||||
|
||||
for _W_dll in ${_W_dll_overrides}; do
|
||||
w_try mv "${W_TMP}/${_W_package_dir}/x32/${_W_dll}.dll" "${W_SYSTEM32_DLLS}/"
|
||||
done
|
||||
|
||||
if test "${W_ARCH}" = "win64"; then
|
||||
for _W_dll in ${_W_dll_overrides}; do
|
||||
w_try mv "${W_TMP}/${_W_package_dir}/x64/${_W_dll}.dll" "${W_SYSTEM64_DLLS}/"
|
||||
done
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
w_override_dlls native ${_W_dll_overrides}
|
||||
|
||||
unset _W_dll _W_dll_overrides _W_invalid_overrides _W_min_vulkan_version _W_min_wine_version \
|
||||
_W_package_archive _W_package_dir _W_package_version \
|
||||
_W_repository _W_supported_overrides
|
||||
}
|
||||
|
||||
#----------------------------------------------------------------
|
||||
|
||||
w_metadata dxvk_async dlls \
|
||||
title="DXVK with Async and GPL patches [USE AT OWN RISK IN GAMES WITH ANTICHEAT] (latest)" \
|
||||
publisher="Ph42oN" \
|
||||
year="2025" \
|
||||
media="download" \
|
||||
installed_file1="${W_SYSTEM32_DLLS_WIN}/d3d8.dll" \
|
||||
installed_file2="${W_SYSTEM32_DLLS_WIN}/d3d9.dll" \
|
||||
installed_file3="${W_SYSTEM32_DLLS_WIN}/d3d10core.dll" \
|
||||
installed_file4="${W_SYSTEM32_DLLS_WIN}/d3d11.dll" \
|
||||
installed_file5="${W_SYSTEM32_DLLS_WIN}/dxgi.dll"
|
||||
|
||||
load_dxvk_async()
|
||||
{
|
||||
# https://gitlab.com/Ph42oN/dxvk-gplasync
|
||||
_W_dxvk_async_version="$(w_get_gitlab_latest_release Ph42oN dxvk-gplasync)"
|
||||
_W_dxvk_async_version="${_W_dxvk_async_version#v}"
|
||||
w_linkcheck_ignore=1 w_download "https://gitlab.com/Ph42oN/dxvk-gplasync/-/raw/main/releases/dxvk-gplasync-v${_W_dxvk_async_version}.tar.gz"
|
||||
helper_dxvk_async "dxvk-gplasync-v${_W_dxvk_async_version}.tar.gz" "${_W_dxvk_async_version}" "7.1" "1.3.204" "dxgi,d3d8,d3d9,d3d10core,d3d11"
|
||||
unset _W_dxvk_async_version
|
||||
}
|
||||
|
||||
#----------------------------------------------------------------
|
||||
|
||||
# $1 - dxvk-nvapi archive name (required)
|
||||
# $2 - minimum Wine version (required)
|
||||
# $3 - nvapi,[nvapi64] (required)
|
||||
@@ -9787,7 +10032,7 @@ load_dsound()
|
||||
|
||||
# Don't try to register native dsound; it doesn't export DllRegisterServer().
|
||||
#w_try_regsvr32 dsound.dll
|
||||
w_override_dlls native dsound
|
||||
w_override_dlls native,builtin dsound
|
||||
}
|
||||
|
||||
#----------------------------------------------------------------
|
||||
@@ -12734,7 +12979,7 @@ load_vcrun2003()
|
||||
{
|
||||
# Sadly, I know of no Microsoft URL for these
|
||||
# winetricks-test can't handle ${file1} in url since it does a raw parsing :/
|
||||
w_download https://sourceforge.net/projects/bzflag/files/bzedit%20win32/1.6.5/BZEditW32_1.6.5.exe 84d1bda5dbf814742898a2e1c0e4bc793e9bc1fba4b7a93d59a7ef12bd0fd802
|
||||
w_download https://downloads.sourceforge.net/project/bzflag/bzedit%20win32/1.6.5/BZEditW32_1.6.5.exe 84d1bda5dbf814742898a2e1c0e4bc793e9bc1fba4b7a93d59a7ef12bd0fd802
|
||||
|
||||
w_try_7z "${W_SYSTEM32_DLLS}" "${W_CACHE}/vcrun2003/BZEditW32_1.6.5.exe" "mfc71.dll" "msvcp71.dll" "msvcr71.dll" -y
|
||||
}
|
||||
@@ -12749,7 +12994,7 @@ w_metadata mfc71 dlls \
|
||||
|
||||
load_mfc71()
|
||||
{
|
||||
w_download_to vcrun2003 https://sourceforge.net/projects/bzflag/files/bzedit%20win32/1.6.5/BZEditW32_1.6.5.exe 84d1bda5dbf814742898a2e1c0e4bc793e9bc1fba4b7a93d59a7ef12bd0fd802
|
||||
w_download_to vcrun2003 https://downloads.sourceforge.net/project/bzflag/bzedit%20win32/1.6.5/BZEditW32_1.6.5.exe 84d1bda5dbf814742898a2e1c0e4bc793e9bc1fba4b7a93d59a7ef12bd0fd802
|
||||
|
||||
w_try_7z "${W_SYSTEM32_DLLS}" "${W_CACHE}/vcrun2003/BZEditW32_1.6.5.exe" "mfc71.dll" -y
|
||||
}
|
||||
@@ -15247,40 +15492,6 @@ load_emu8086()
|
||||
|
||||
#----------------------------------------------------------------
|
||||
|
||||
w_metadata ev3 apps \
|
||||
title="Lego Mindstorms EV3 Home Edition" \
|
||||
publisher="Lego" \
|
||||
year="2014" \
|
||||
media="download" \
|
||||
file1="LMS-EV3-WIN32-ENUS-01-02-01-full-setup.exe" \
|
||||
installed_exe1="${W_PROGRAMS_X86_WIN}/LEGO Software/LEGO MINDSTORMS EV3 Home Edition/MindstormsEV3.exe"
|
||||
|
||||
load_ev3()
|
||||
{
|
||||
if w_workaround_wine_bug 40192 "Installing vcrun2005 as Wine does not have MFC80.dll"; then
|
||||
w_call vcrun2005
|
||||
fi
|
||||
|
||||
if w_workaround_wine_bug 40193 "Installing IE8 as built-in Gecko is not sufficient"; then
|
||||
w_call ie8
|
||||
fi
|
||||
|
||||
w_call dotnet40
|
||||
|
||||
# 2016/03/22: LMS-EV3-WIN32-ENUS-01-02-01-full-setup.exe c47341f08242f0f6f01996530e7c93bda2d666747ada60ab93fa773a55d40a19
|
||||
|
||||
w_download http://esd.lego.com.edgesuite.net/digitaldelivery/mindstorms/6ecda7c2-1189-4816-b2dd-440e22d65814/public/LMS-EV3-WIN32-ENUS-01-02-01-full-setup.exe c47341f08242f0f6f01996530e7c93bda2d666747ada60ab93fa773a55d40a19
|
||||
|
||||
w_try_cd "${W_CACHE}"/"${W_PACKAGE}"
|
||||
w_try "${WINE}" "${file1}" ${W_OPT_UNATTENDED:+/qb /AcceptLicenses yes}
|
||||
|
||||
if w_workaround_wine_bug 40729 "Setting override for urlmon.dll to native to avoid crash"; then
|
||||
w_override_dlls native urlmon
|
||||
fi
|
||||
}
|
||||
|
||||
#----------------------------------------------------------------
|
||||
|
||||
w_metadata firefox apps \
|
||||
title="Firefox 51.0" \
|
||||
publisher="Mozilla" \
|
||||
@@ -17828,6 +18039,32 @@ w_metadata windowmanagerdecorated=n settings \
|
||||
|
||||
#----------------------------------------------------------------
|
||||
|
||||
w_metadata useegl=y settings \
|
||||
title="Enable EGL (default)"
|
||||
w_metadata useegl=n settings \
|
||||
title="Disable EGL, use GLX instead"
|
||||
|
||||
load_useegl()
|
||||
{
|
||||
case "$1" in
|
||||
y) arg="Y";;
|
||||
n) arg="N";;
|
||||
*) w_die "illegal value $1 for UseEGL";;
|
||||
esac
|
||||
|
||||
echo "Setting UseEGL to ${arg}"
|
||||
cat > "${W_TMP}"/set-useegl.reg <<_EOF_
|
||||
REGEDIT4
|
||||
|
||||
[HKEY_CURRENT_USER\\Software\\Wine\\X11 Driver]
|
||||
"UseEGL"="${arg}"
|
||||
|
||||
_EOF_
|
||||
w_try_regedit "${W_TMP}"/set-useegl.reg
|
||||
}
|
||||
|
||||
#----------------------------------------------------------------
|
||||
|
||||
w_metadata usetakefocus=y settings \
|
||||
title_bg="Включете UseTakeFocus" \
|
||||
title_cz="Aktivovat UseTakeFocus" \
|
||||
|
||||
@@ -14,8 +14,8 @@ pycryptodome>=3.19.0
|
||||
# Configuration file handling
|
||||
PyYAML>=6.0
|
||||
|
||||
# Steam VDF file parsing (latest available version)
|
||||
vdf>=3.4
|
||||
# Steam VDF file parsing (actively maintained fork, used by Gentoo)
|
||||
vdf @ git+https://github.com/solsticegamestudios/vdf.git
|
||||
|
||||
# Package version handling
|
||||
packaging>=21.0
|
||||
|
||||
Reference in New Issue
Block a user