Sync from development - prepare for v0.2.0.9

This commit is contained in:
Omni
2025-12-31 20:56:47 +00:00
parent 2511c9334c
commit 0d84d2f2fe
14 changed files with 373 additions and 178 deletions

View File

@@ -1,5 +1,23 @@
# Jackify Changelog # Jackify Changelog
## v0.2.0.9 - Critical Configuration Fixes
**Release Date:** 2025-12-31
### Bug Fixes
- Fixed AppID conversion bug causing Configure Existing failures
- Fixed missing MessageService import crash in Configure Existing
- Fixed RecursionError in config_handler.py logger
- Fixed winetricks automatic fallback to protontricks (was silently failing)
### Improvements
- Added detailed progress indicators for configuration workflows
- Fixed progress bar completion showing 100% instead of 95%
- Removed debug logging noise from file progress widget
- Enhanced Premium detection diagnostics for Issue #111
- Flatpak protontricks now auto-granted cache access for faster subsequent installs
---
## v0.2.0.8 - Bug Fixes and Improvements ## v0.2.0.8 - Bug Fixes and Improvements
**Release Date:** 2025-12-29 **Release Date:** 2025-12-29

View File

@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems. Wabbajack modlists natively on Linux systems.
""" """
__version__ = "0.2.0.8" __version__ = "0.2.0.9"

View File

@@ -6,6 +6,7 @@ Handles application settings and configuration
""" """
import os import os
import sys
import json import json
import logging import logging
import shutil import shutil
@@ -211,7 +212,8 @@ class ConfigHandler:
config.update(saved_config) config.update(saved_config)
return config return config
except Exception as e: except Exception as e:
logger.error(f"Error reading configuration from disk: {e}") # Don't use logger here - can cause recursion if logger tries to access config
print(f"Warning: Error reading configuration from disk: {e}", file=sys.stderr)
return self.settings.copy() return self.settings.copy()
def reload_config(self): def reload_config(self):

View File

@@ -571,15 +571,19 @@ class ModlistMenuHandler:
self.logger.warning(f"[DEBUG] Could not find AppID for {context['name']} with exe {context['mo2_exe_path']}") self.logger.warning(f"[DEBUG] Could not find AppID for {context['name']} with exe {context['mo2_exe_path']}")
set_modlist_result = self.modlist_handler.set_modlist(context) set_modlist_result = self.modlist_handler.set_modlist(context)
self.logger.debug(f"[DEBUG] set_modlist returned: {set_modlist_result}") self.logger.debug(f"[DEBUG] set_modlist returned: {set_modlist_result}")
# Check GUI mode early to avoid input() calls in GUI context
import os
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
if not set_modlist_result: if not set_modlist_result:
print(f"{COLOR_ERROR}\nError setting up context for configuration.{COLOR_RESET}") print(f"{COLOR_ERROR}\nError setting up context for configuration.{COLOR_RESET}")
self.logger.error(f"set_modlist failed for {context.get('name')}") self.logger.error(f"set_modlist failed for {context.get('name')}")
if not gui_mode:
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
return False return False
# --- Resolution selection logic for GUI mode --- # --- Resolution selection logic for GUI mode ---
import os
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
selected_resolution = context.get('resolution', None) selected_resolution = context.get('resolution', None)
if gui_mode: if gui_mode:
# If resolution is provided, set it and do not prompt # If resolution is provided, set it and do not prompt
@@ -654,7 +658,8 @@ class ModlistMenuHandler:
print("NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of") print("NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of")
print(" Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10).") print(" Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10).")
print("") print("")
print("Detailed log available at: ~/Jackify/logs/Configure_New_Modlist_workflow.log") from jackify.shared.paths import get_jackify_logs_dir
print(f"Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log")
# Only wait for input in CLI mode, not GUI mode # Only wait for input in CLI mode, not GUI mode
if not gui_mode: if not gui_mode:
input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}") input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}")

View File

@@ -777,6 +777,56 @@ class ProtontricksHandler:
self.logger.error(f"Error running protontricks-launch: {e}") self.logger.error(f"Error running protontricks-launch: {e}")
return None return None
def _ensure_flatpak_cache_access(self, cache_path: Path) -> bool:
"""
Ensure flatpak protontricks has filesystem access to the winetricks cache.
Args:
cache_path: Path to winetricks cache directory
Returns:
True if access granted or already exists, False on failure
"""
if self.which_protontricks != 'flatpak':
return True # Not flatpak, no action needed
try:
# Check if flatpak already has access to this path
result = subprocess.run(
['flatpak', 'override', '--user', '--show', 'com.github.Matoking.protontricks'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
# Check if cache path is already in filesystem overrides
cache_str = str(cache_path.resolve())
if f'filesystems=' in result.stdout and cache_str in result.stdout:
self.logger.debug(f"Flatpak protontricks already has access to cache: {cache_str}")
return True
# Grant access to cache directory
self.logger.info(f"Granting flatpak protontricks access to winetricks cache: {cache_path}")
result = subprocess.run(
['flatpak', 'override', '--user', 'com.github.Matoking.protontricks',
f'--filesystem={cache_path.resolve()}'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
self.logger.info("Successfully granted flatpak protontricks cache access")
return True
else:
self.logger.warning(f"Failed to grant flatpak cache access: {result.stderr}")
return False
except Exception as e:
self.logger.warning(f"Could not configure flatpak cache access: {e}")
return False
def install_wine_components(self, appid, game_var, specific_components: Optional[List[str]] = None): def install_wine_components(self, appid, game_var, specific_components: Optional[List[str]] = None):
""" """
Install the specified Wine components into the given prefix using protontricks. Install the specified Wine components into the given prefix using protontricks.
@@ -820,6 +870,10 @@ class ProtontricksHandler:
from jackify.shared.paths import get_jackify_data_dir from jackify.shared.paths import get_jackify_data_dir
jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache' jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache'
jackify_cache_dir.mkdir(parents=True, exist_ok=True) jackify_cache_dir.mkdir(parents=True, exist_ok=True)
# Ensure flatpak protontricks has access to cache (no-op for native)
self._ensure_flatpak_cache_access(jackify_cache_dir)
env['WINETRICKS_CACHE'] = str(jackify_cache_dir) env['WINETRICKS_CACHE'] = str(jackify_cache_dir)
self.logger.info(f"Using winetricks cache: {jackify_cache_dir}") self.logger.info(f"Using winetricks cache: {jackify_cache_dir}")
if specific_components is not None: if specific_components is not None:

View File

@@ -955,7 +955,10 @@ class ShortcutHandler:
def get_appid_for_shortcut(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]: def get_appid_for_shortcut(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]:
""" """
Find the current AppID for a given shortcut name and (optionally) executable path using protontricks. Find the current AppID for a given shortcut name and (optionally) executable path.
Primary method: Read directly from shortcuts.vdf (reliable, no external dependencies)
Fallback method: Use protontricks (if available)
Args: Args:
shortcut_name (str): The name of the Steam shortcut. shortcut_name (str): The name of the Steam shortcut.
@@ -965,15 +968,22 @@ class ShortcutHandler:
Optional[str]: The found AppID string, or None if not found or error occurs. Optional[str]: The found AppID string, or None if not found or error occurs.
""" """
self.logger.info(f"Attempting to find current AppID for shortcut: '{shortcut_name}' (exe_path: '{exe_path}')") self.logger.info(f"Attempting to find current AppID for shortcut: '{shortcut_name}' (exe_path: '{exe_path}')")
try: try:
from .protontricks_handler import ProtontricksHandler # Local import appid = self.get_appid_from_vdf(shortcut_name, exe_path)
if appid:
self.logger.info(f"Successfully found AppID {appid} from shortcuts.vdf")
return appid
self.logger.info("AppID not found in shortcuts.vdf, trying protontricks as fallback...")
from .protontricks_handler import ProtontricksHandler
pt_handler = ProtontricksHandler(self.steamdeck) pt_handler = ProtontricksHandler(self.steamdeck)
if not pt_handler.detect_protontricks(): if not pt_handler.detect_protontricks():
self.logger.error("Protontricks not detected") self.logger.warning("Protontricks not detected - cannot use as fallback")
return None return None
result = pt_handler.run_protontricks("-l") result = pt_handler.run_protontricks("-l")
if not result or result.returncode != 0: if not result or result.returncode != 0:
self.logger.error(f"Protontricks failed to list applications: {result.stderr if result else 'No result'}") self.logger.warning(f"Protontricks fallback failed: {result.stderr if result else 'No result'}")
return None return None
# Build a list of all shortcuts # Build a list of all shortcuts
found_shortcuts = [] found_shortcuts = []
@@ -1022,6 +1032,62 @@ class ShortcutHandler:
self.logger.exception("Traceback:") self.logger.exception("Traceback:")
return None return None
def get_appid_from_vdf(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]:
"""
Get AppID directly from shortcuts.vdf by reading the file and matching shortcut name/exe.
This is more reliable than using protontricks since it doesn't depend on external tools.
Args:
shortcut_name (str): The name of the Steam shortcut.
exe_path (Optional[str]): The path to the executable for additional validation.
Returns:
Optional[str]: The AppID as a string, or None if not found.
"""
self.logger.info(f"Looking up AppID from shortcuts.vdf for shortcut: '{shortcut_name}' (exe: '{exe_path}')")
if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path):
self.logger.warning(f"Shortcuts.vdf not found at {self.shortcuts_path}")
return None
try:
shortcuts_data = VDFHandler.load(self.shortcuts_path, binary=True)
if not shortcuts_data or 'shortcuts' not in shortcuts_data:
self.logger.warning("No shortcuts found in shortcuts.vdf")
return None
shortcut_name_clean = shortcut_name.strip().lower()
for idx, shortcut in shortcuts_data['shortcuts'].items():
name = shortcut.get('AppName', shortcut.get('appname', '')).strip()
if name.lower() == shortcut_name_clean:
appid = shortcut.get('appid')
if appid:
if exe_path:
vdf_exe = shortcut.get('Exe', shortcut.get('exe', '')).strip('"').strip()
exe_path_norm = os.path.abspath(os.path.expanduser(exe_path)).lower()
vdf_exe_norm = os.path.abspath(os.path.expanduser(vdf_exe)).lower()
if vdf_exe_norm == exe_path_norm:
self.logger.info(f"Found AppID {appid} for shortcut '{name}' with matching exe '{vdf_exe}'")
return str(int(appid) & 0xFFFFFFFF)
else:
self.logger.debug(f"Found shortcut '{name}' but exe doesn't match: '{vdf_exe}' vs '{exe_path}'")
continue
else:
self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)")
return str(int(appid) & 0xFFFFFFFF)
self.logger.warning(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
return None
except Exception as e:
self.logger.error(f"Error reading shortcuts.vdf: {e}")
self.logger.exception("Traceback:")
return None
# --- Discovery Methods Moved from ModlistHandler --- # --- Discovery Methods Moved from ModlistHandler ---
def _scan_shortcuts_for_executable(self, executable_name: str) -> List[Dict[str, str]]: def _scan_shortcuts_for_executable(self, executable_name: str) -> List[Dict[str, str]]:

View File

@@ -468,7 +468,7 @@ class WinetricksHandler:
from .protontricks_handler import ProtontricksHandler from .protontricks_handler import ProtontricksHandler
steamdeck = os.path.exists('/home/deck') steamdeck = os.path.exists('/home/deck')
protontricks_handler = ProtontricksHandler(steamdeck) protontricks_handler = ProtontricksHandler(steamdeck)
protontricks_available = protontricks_handler.is_available() protontricks_available = protontricks_handler.detect_protontricks()
if protontricks_available: if protontricks_available:
self.logger.warning("=" * 80) self.logger.warning("=" * 80)

View File

@@ -493,7 +493,7 @@ exit"""
def detect_actual_prefix_appid(self, initial_appid: int, shortcut_name: str) -> Optional[int]: def detect_actual_prefix_appid(self, initial_appid: int, shortcut_name: str) -> Optional[int]:
""" """
After Steam restart, detect the actual prefix AppID that was created. After Steam restart, detect the actual prefix AppID that was created.
Use protontricks -l to find the actual positive AppID. Uses direct VDF file reading to find the actual AppID.
Args: Args:
initial_appid: The initial (negative) AppID from shortcuts.vdf initial_appid: The initial (negative) AppID from shortcuts.vdf
@@ -503,42 +503,42 @@ exit"""
The actual (positive) AppID of the created prefix, or None if not found The actual (positive) AppID of the created prefix, or None if not found
""" """
try: try:
logger.info(f"Using protontricks -l to detect actual AppID for shortcut: {shortcut_name}") logger.info(f"Using VDF to detect actual AppID for shortcut: {shortcut_name}")
# Wait up to 30 seconds for the shortcut to appear in protontricks # Wait up to 30 seconds for Steam to process the shortcut
for i in range(30): for i in range(30):
try: try:
# Use the existing protontricks handler from ..handlers.shortcut_handler import ShortcutHandler
from jackify.backend.handlers.protontricks_handler import ProtontricksHandler from ..handlers.path_handler import PathHandler
protontricks_handler = ProtontricksHandler(steamdeck or False)
result = protontricks_handler.run_protontricks('-l')
if result.returncode == 0: path_handler = PathHandler()
lines = result.stdout.strip().split('\n') shortcuts_path = path_handler._find_shortcuts_vdf()
# Look for our shortcut name in the protontricks output if shortcuts_path:
for line in lines: from ..handlers.vdf_handler import VDFHandler
if shortcut_name in line and 'Non-Steam shortcut:' in line: shortcuts_data = VDFHandler.load(shortcuts_path, binary=True)
# Extract AppID from line like "Non-Steam shortcut: Tuxborn (3106560878)"
if '(' in line and ')' in line: if shortcuts_data and 'shortcuts' in shortcuts_data:
appid_str = line.split('(')[1].split(')')[0] for idx, shortcut in shortcuts_data['shortcuts'].items():
actual_appid = int(appid_str) app_name = shortcut.get('AppName', shortcut.get('appname', '')).strip()
logger.info(f" Found shortcut in protontricks: {line.strip()}")
logger.info(f" Initial AppID: {initial_appid}") if app_name.lower() == shortcut_name.lower():
logger.info(f" Actual AppID: {actual_appid}") appid = shortcut.get('appid')
if appid:
actual_appid = int(appid) & 0xFFFFFFFF
logger.info(f"Found shortcut '{app_name}' in shortcuts.vdf")
logger.info(f" Initial AppID (signed): {initial_appid}")
logger.info(f" Actual AppID (unsigned): {actual_appid}")
return actual_appid return actual_appid
logger.debug(f"Shortcut '{shortcut_name}' not found in protontricks yet (attempt {i+1}/30)") logger.debug(f"Shortcut '{shortcut_name}' not found in VDF yet (attempt {i+1}/30)")
time.sleep(1) time.sleep(1)
except subprocess.TimeoutExpired:
logger.warning(f"protontricks -l timed out on attempt {i+1}")
time.sleep(1)
except Exception as e: except Exception as e:
logger.warning(f"Error running protontricks -l on attempt {i+1}: {e}") logger.warning(f"Error reading shortcuts.vdf on attempt {i+1}: {e}")
time.sleep(1) time.sleep(1)
logger.error(f"Shortcut '{shortcut_name}' not found in protontricks after 30 seconds") logger.error(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf after 30 seconds")
return None return None
except Exception as e: except Exception as e:

View File

@@ -15,29 +15,32 @@ _KEYWORD_PHRASES = (
) )
def is_non_premium_indicator(line: str) -> bool: def is_non_premium_indicator(line: str) -> tuple[bool, str | None]:
""" """
Return True if the engine output line indicates a Nexus non-premium scenario. Return True if the engine output line indicates a Nexus non-premium scenario.
Args: Args:
line: Raw line emitted from the jackify-engine process. line: Raw line emitted from the jackify-engine process.
Returns:
Tuple of (is_premium_error: bool, matched_pattern: str | None)
""" """
if not line: if not line:
return False return False, None
normalized = line.strip().lower() normalized = line.strip().lower()
if not normalized: if not normalized:
return False return False, None
# Direct phrase detection # Direct phrase detection
for phrase in _KEYWORD_PHRASES[:6]: for phrase in _KEYWORD_PHRASES[:6]:
if phrase in normalized: if phrase in normalized:
return True return True, phrase
# Manual download + Nexus URL implies premium requirement in current workflows. # Manual download + Nexus URL implies premium requirement in current workflows.
if "manual download" in normalized and ("nexusmods.com" in normalized or "nexus mods" in normalized): if "manual download" in normalized and ("nexusmods.com" in normalized or "nexus mods" in normalized):
return True return True, "manual download + nexusmods.com"
return False return False, None

View File

@@ -16,6 +16,8 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap, QIcon from PySide6.QtGui import QPixmap, QIcon
from jackify.shared.paths import get_jackify_logs_dir
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -198,6 +200,6 @@ Modlist Install and Configuration complete!:
NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of
Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10). Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10).
Detailed log available at: ~/Jackify/logs/Configure_New_Modlist_workflow.log""" Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log"""
return completion_text return completion_text

View File

@@ -22,6 +22,7 @@ from jackify.backend.services.api_key_service import APIKeyService
from jackify.backend.services.resolution_service import ResolutionService from jackify.backend.services.resolution_service import ResolutionService
from jackify.backend.handlers.config_handler import ConfigHandler from jackify.backend.handlers.config_handler import ConfigHandler
from ..dialogs import SuccessDialog from ..dialogs import SuccessDialog
from jackify.frontends.gui.services.message_service import MessageService
def debug_print(message): def debug_print(message):
"""Print debug message only if debug mode is enabled""" """Print debug message only if debug mode is enabled"""
@@ -522,22 +523,20 @@ class ConfigureExistingModlistScreen(QWidget):
message_lower = text.lower() message_lower = text.lower()
# Update progress indicator based on key status messages # Update progress indicator based on key status messages
if "creating steam shortcut" in message_lower: if "setting protontricks permissions" in message_lower or "permissions" in message_lower:
self.progress_indicator.set_status("Creating Steam shortcut...", 10) self.progress_indicator.set_status("Setting permissions...", 20)
elif "restarting steam" in message_lower or "restart steam" in message_lower: elif "applying curated registry" in message_lower or "registry" in message_lower:
self.progress_indicator.set_status("Restarting Steam...", 20) self.progress_indicator.set_status("Applying registry files...", 40)
elif "steam restart" in message_lower and "success" in message_lower: elif "installing wine components" in message_lower or "wine component" in message_lower:
self.progress_indicator.set_status("Steam restarted successfully", 30) self.progress_indicator.set_status("Installing wine components...", 60)
elif "creating proton prefix" in message_lower or "prefix creation" in message_lower: elif "dotnet" in message_lower and "fix" in message_lower:
self.progress_indicator.set_status("Creating Proton prefix...", 50) self.progress_indicator.set_status("Applying dotnet fixes...", 75)
elif "prefix created" in message_lower or "prefix creation" in message_lower and "success" in message_lower: elif "setting ownership" in message_lower or "ownership and permissions" in message_lower:
self.progress_indicator.set_status("Proton prefix created", 70) self.progress_indicator.set_status("Setting permissions...", 85)
elif "verifying" in message_lower: elif "verifying" in message_lower:
self.progress_indicator.set_status("Verifying setup...", 80) self.progress_indicator.set_status("Verifying setup...", 90)
elif "steam integration complete" in message_lower or "configuration complete" in message_lower: elif "steam integration complete" in message_lower or "configuration complete" in message_lower:
self.progress_indicator.set_status("Configuration complete", 95) self.progress_indicator.set_status("Configuration complete", 100)
elif "complete" in message_lower and not "prefix" in message_lower:
self.progress_indicator.set_status("Finishing up...", 90)
# Update activity window with generic configuration status # Update activity window with generic configuration status
# Only update if message contains meaningful progress (not blank lines or separators) # Only update if message contains meaningful progress (not blank lines or separators)

View File

@@ -612,12 +612,18 @@ class ConfigureNewModlistScreen(QWidget):
self.progress_indicator.set_status("Creating Proton prefix...", 50) self.progress_indicator.set_status("Creating Proton prefix...", 50)
elif "prefix created" in message_lower or "prefix creation" in message_lower and "success" in message_lower: elif "prefix created" in message_lower or "prefix creation" in message_lower and "success" in message_lower:
self.progress_indicator.set_status("Proton prefix created", 70) self.progress_indicator.set_status("Proton prefix created", 70)
elif "applying curated registry" in message_lower or "registry" in message_lower:
self.progress_indicator.set_status("Applying registry files...", 75)
elif "installing wine components" in message_lower or "wine component" in message_lower:
self.progress_indicator.set_status("Installing wine components...", 80)
elif "dotnet" in message_lower and "fix" in message_lower:
self.progress_indicator.set_status("Applying dotnet fixes...", 85)
elif "setting ownership" in message_lower or "ownership and permissions" in message_lower:
self.progress_indicator.set_status("Setting permissions...", 90)
elif "verifying" in message_lower: elif "verifying" in message_lower:
self.progress_indicator.set_status("Verifying setup...", 80) self.progress_indicator.set_status("Verifying setup...", 95)
elif "steam integration complete" in message_lower or "configuration complete" in message_lower: elif "steam integration complete" in message_lower or "configuration complete" in message_lower:
self.progress_indicator.set_status("Configuration complete", 95) self.progress_indicator.set_status("Configuration complete", 100)
elif "complete" in message_lower and not "prefix" in message_lower:
self.progress_indicator.set_status("Finishing up...", 90)
# Update activity window with generic configuration status # Update activity window with generic configuration status
# Only update if message contains meaningful progress (not blank lines or separators) # Only update if message contains meaningful progress (not blank lines or separators)

View File

@@ -1881,6 +1881,26 @@ class InstallModlistScreen(QWidget):
) )
return return
# Log authentication status at install start (Issue #111 diagnostics)
import logging
logger = logging.getLogger(__name__)
auth_method = self.auth_service.get_auth_method()
logger.info("=" * 60)
logger.info("Authentication Status at Install Start")
logger.info(f"Method: {auth_method or 'UNKNOWN'}")
logger.info(f"Token length: {len(api_key)} chars")
if len(api_key) >= 8:
logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}")
if auth_method == 'oauth':
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
if 'expires_in_minutes' in token_info:
logger.info(f"OAuth expires in: {token_info['expires_in_minutes']:.1f} minutes")
if token_info.get('refresh_token_likely_expired'):
logger.warning(f"OAuth refresh token age: {token_info['refresh_token_age_days']:.1f} days (may need re-auth)")
logger.info("=" * 60)
modlist_name = self.modlist_name_edit.text().strip() modlist_name = self.modlist_name_edit.text().strip()
missing_fields = [] missing_fields = []
if not modlist_name: if not modlist_name:
@@ -2129,6 +2149,9 @@ class InstallModlistScreen(QWidget):
# R&D: Progress state manager for parsing # R&D: Progress state manager for parsing
self.progress_state_manager = progress_state_manager self.progress_state_manager = progress_state_manager
self._premium_signal_sent = False self._premium_signal_sent = False
# Rolling buffer for Premium detection diagnostics
self._engine_output_buffer = []
self._buffer_size = 10
def cancel(self): def cancel(self):
self.cancelled = True self.cancelled = True
@@ -2196,10 +2219,71 @@ class InstallModlistScreen(QWidget):
from jackify.backend.handlers.config_handler import ConfigHandler from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler() config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False) debug_mode = config_handler.get('debug_mode', False)
if not self._premium_signal_sent and is_non_premium_indicator(decoded):
# Check for Premium detection
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
if not self._premium_signal_sent and is_premium_error:
self._premium_signal_sent = True self._premium_signal_sent = True
# DIAGNOSTIC LOGGING: Capture false positive details
import logging
logger = logging.getLogger(__name__)
logger.warning("=" * 80)
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
logger.warning("=" * 80)
logger.warning(f"Matched pattern: '{matched_pattern}'")
logger.warning(f"Triggering line: '{decoded.strip()}'")
# Detailed auth diagnostics
logger.warning("")
logger.warning("AUTHENTICATION DIAGNOSTICS:")
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
if self.api_key:
logger.warning(f" Auth value length: {len(self.api_key)} chars")
if len(self.api_key) >= 8:
logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}")
# Determine auth method and get detailed status
auth_method = self.auth_service.get_auth_method()
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
if auth_method == 'oauth':
# Get detailed OAuth token status
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
logger.warning(" OAuth Token Status:")
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
if 'expires_in_minutes' in token_info:
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
logger.warning(f" Is expired: {token_info.get('is_expired', False)}")
logger.warning(f" Expires soon (5min): {token_info.get('expires_soon_5min', False)}")
if 'refresh_token_age_days' in token_info:
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
logger.warning(f" Refresh token likely expired: {token_info.get('refresh_token_likely_expired', False)}")
if token_info.get('error'):
logger.warning(f" Error: {token_info['error']}")
logger.warning("")
logger.warning("Previous engine output (last 10 lines):")
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
logger.warning("")
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
logger.warning("Report to: https://github.com/Omni-guides/Jackify/issues/111")
logger.warning("=" * 80)
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
# Maintain rolling buffer of engine output for diagnostics
self._engine_output_buffer.append(decoded.strip())
if len(self._engine_output_buffer) > self._buffer_size:
self._engine_output_buffer.pop(0)
# R&D: Process through progress parser # R&D: Process through progress parser
if self.progress_state_manager: if self.progress_state_manager:
updated = self.progress_state_manager.process_line(decoded) updated = self.progress_state_manager.process_line(decoded)
@@ -2223,10 +2307,69 @@ class InstallModlistScreen(QWidget):
decoded = line.decode('utf-8', errors='replace') decoded = line.decode('utf-8', errors='replace')
# Notify when Nexus requires Premium before continuing # Notify when Nexus requires Premium before continuing
if not self._premium_signal_sent and is_non_premium_indicator(decoded): is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
if not self._premium_signal_sent and is_premium_error:
self._premium_signal_sent = True self._premium_signal_sent = True
# DIAGNOSTIC LOGGING: Capture false positive details
import logging
logger = logging.getLogger(__name__)
logger.warning("=" * 80)
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
logger.warning("=" * 80)
logger.warning(f"Matched pattern: '{matched_pattern}'")
logger.warning(f"Triggering line: '{decoded.strip()}'")
# Detailed auth diagnostics
logger.warning("")
logger.warning("AUTHENTICATION DIAGNOSTICS:")
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
if self.api_key:
logger.warning(f" Auth value length: {len(self.api_key)} chars")
if len(self.api_key) >= 8:
logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}")
# Determine auth method and get detailed status
auth_method = self.auth_service.get_auth_method()
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
if auth_method == 'oauth':
# Get detailed OAuth token status
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
logger.warning(" OAuth Token Status:")
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
if 'expires_in_minutes' in token_info:
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
logger.warning(f" Is expired: {token_info.get('is_expired', False)}")
logger.warning(f" Expires soon (5min): {token_info.get('expires_soon_5min', False)}")
if 'refresh_token_age_days' in token_info:
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
logger.warning(f" Refresh token likely expired: {token_info.get('refresh_token_likely_expired', False)}")
if token_info.get('error'):
logger.warning(f" Error: {token_info['error']}")
logger.warning("")
logger.warning("Previous engine output (last 10 lines):")
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
logger.warning("")
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
logger.warning("Report to: https://github.com/Omni-guides/Jackify/issues/111")
logger.warning("=" * 80)
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
# Maintain rolling buffer of engine output for diagnostics
self._engine_output_buffer.append(decoded.strip())
if len(self._engine_output_buffer) > self._buffer_size:
self._engine_output_buffer.pop(0)
# R&D: Process through progress parser # R&D: Process through progress parser
from jackify.backend.handlers.config_handler import ConfigHandler from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler() config_handler = ConfigHandler()

View File

@@ -492,27 +492,13 @@ class FileProgressList(QWidget):
# Widget doesn't exist - create it (only clear when creating new widget) # Widget doesn't exist - create it (only clear when creating new widget)
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets # CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
_debug_log(f"[WIDGET_FIX] About to clear list_widget for summary widget - count={self.list_widget.count()}")
for i in range(self.list_widget.count()): for i in range(self.list_widget.count()):
item = self.list_widget.item(i) item = self.list_widget.item(i)
if item: if item:
widget = self.list_widget.itemWidget(item) widget = self.list_widget.itemWidget(item)
if widget: if widget:
_debug_log(f"[WIDGET_FIX] Removing widget before clear (summary) - widget={widget}, parent={widget.parent()}, isWindow()={widget.isWindow()}")
self.list_widget.removeItemWidget(item) self.list_widget.removeItemWidget(item)
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Widget became top-level window after removeItemWidget() before clear()!")
import traceback
traceback.print_stack()
self.list_widget.clear() self.list_widget.clear()
# Check widgets in _file_items dict after clear
for key, widget in list(self._file_items.items()):
if widget:
_debug_log(f"[WIDGET_FIX] Widget in _file_items after clear - key={key}, widget={widget}, parent={widget.parent()}, isWindow()={widget.isWindow()}")
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Widget in _file_items is a top-level window after clear()! This is the bug!")
import traceback
traceback.print_stack()
self._file_items.clear() self._file_items.clear()
# Create new summary widget # Create new summary widget
@@ -541,19 +527,8 @@ class FileProgressList(QWidget):
# CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets # CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets
widget = self.list_widget.itemWidget(item) widget = self.list_widget.itemWidget(item)
if widget: if widget:
_debug_log(f"[WIDGET_FIX] Removing summary widget - widget={widget}, parent={widget.parent()}, isWindow()={widget.isWindow()}")
self.list_widget.removeItemWidget(item) self.list_widget.removeItemWidget(item)
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Summary widget became top-level window after removeItemWidget()!")
import traceback
traceback.print_stack()
self.list_widget.takeItem(i) self.list_widget.takeItem(i)
if widget:
_debug_log(f"[WIDGET_FIX] After takeItem (summary) - widget.parent()={widget.parent()}, isWindow()={widget.isWindow()}")
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Summary widget is still a top-level window after takeItem()!")
import traceback
traceback.print_stack()
break break
self._summary_widget = None self._summary_widget = None
else: else:
@@ -568,19 +543,8 @@ class FileProgressList(QWidget):
# CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets # CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets
widget = self.list_widget.itemWidget(item) widget = self.list_widget.itemWidget(item)
if widget: if widget:
_debug_log(f"[WIDGET_FIX] Removing transition label - widget={widget}, parent={widget.parent()}, isWindow()={widget.isWindow()}")
self.list_widget.removeItemWidget(item) self.list_widget.removeItemWidget(item)
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Transition label became top-level window after removeItemWidget()!")
import traceback
traceback.print_stack()
self.list_widget.takeItem(i) self.list_widget.takeItem(i)
if widget:
_debug_log(f"[WIDGET_FIX] After takeItem (transition) - widget.parent()={widget.parent()}, isWindow()={widget.isWindow()}")
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Transition label is still a top-level window after takeItem()!")
import traceback
traceback.print_stack()
break break
self._transition_label = None self._transition_label = None
@@ -592,27 +556,13 @@ class FileProgressList(QWidget):
else: else:
# Show empty state but keep header stable # Show empty state but keep header stable
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets # CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
_debug_log(f"[WIDGET_FIX] About to clear list_widget (empty state) - count={self.list_widget.count()}")
for i in range(self.list_widget.count()): for i in range(self.list_widget.count()):
item = self.list_widget.item(i) item = self.list_widget.item(i)
if item: if item:
widget = self.list_widget.itemWidget(item) widget = self.list_widget.itemWidget(item)
if widget: if widget:
_debug_log(f"[WIDGET_FIX] Removing widget before clear (empty) - widget={widget}, parent={widget.parent()}, isWindow()={widget.isWindow()}")
self.list_widget.removeItemWidget(item) self.list_widget.removeItemWidget(item)
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Widget became top-level window after removeItemWidget() before clear()!")
import traceback
traceback.print_stack()
self.list_widget.clear() self.list_widget.clear()
# Check widgets in _file_items dict after clear
for key, widget in list(self._file_items.items()):
if widget:
_debug_log(f"[WIDGET_FIX] Widget in _file_items after clear (empty) - key={key}, widget={widget}, parent={widget.parent()}, isWindow()={widget.isWindow()}")
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Widget in _file_items is a top-level window after clear()! This is the bug!")
import traceback
traceback.print_stack()
self._file_items.clear() self._file_items.clear()
# Update last phase tracker # Update last phase tracker
@@ -661,21 +611,8 @@ class FileProgressList(QWidget):
# CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets # CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets
widget = self.list_widget.itemWidget(item) widget = self.list_widget.itemWidget(item)
if widget: if widget:
_debug_log(f"[WIDGET_FIX] Removing widget for item_key={item_key} - widget={widget}, parent={widget.parent()}, isWindow()={widget.isWindow()}")
self.list_widget.removeItemWidget(item) self.list_widget.removeItemWidget(item)
# Check if widget became orphaned after removal
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Widget became top-level window after removeItemWidget()! widget={widget}")
import traceback
traceback.print_stack()
self.list_widget.takeItem(i) self.list_widget.takeItem(i)
# Final check after takeItem
if widget:
_debug_log(f"[WIDGET_FIX] After takeItem - widget.parent()={widget.parent()}, isWindow()={widget.isWindow()}")
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Widget is still a top-level window after takeItem()! This is the bug!")
import traceback
traceback.print_stack()
break break
del self._file_items[item_key] del self._file_items[item_key]
@@ -735,27 +672,13 @@ class FileProgressList(QWidget):
def _show_transition_message(self, new_phase: str): def _show_transition_message(self, new_phase: str):
"""Show a brief 'Preparing...' message during phase transitions.""" """Show a brief 'Preparing...' message during phase transitions."""
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets # CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
_debug_log(f"[WIDGET_FIX] About to clear list_widget (transition) - count={self.list_widget.count()}")
for i in range(self.list_widget.count()): for i in range(self.list_widget.count()):
item = self.list_widget.item(i) item = self.list_widget.item(i)
if item: if item:
widget = self.list_widget.itemWidget(item) widget = self.list_widget.itemWidget(item)
if widget: if widget:
_debug_log(f"[WIDGET_FIX] Removing widget before clear (transition) - widget={widget}, parent={widget.parent()}, isWindow()={widget.isWindow()}")
self.list_widget.removeItemWidget(item) self.list_widget.removeItemWidget(item)
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Widget became top-level window after removeItemWidget() before clear()!")
import traceback
traceback.print_stack()
self.list_widget.clear() self.list_widget.clear()
# Check widgets in _file_items dict after clear
for key, widget in list(self._file_items.items()):
if widget:
_debug_log(f"[WIDGET_FIX] Widget in _file_items after clear (transition) - key={key}, widget={widget}, parent={widget.parent()}, isWindow()={widget.isWindow()}")
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Widget in _file_items is a top-level window after clear()! This is the bug!")
import traceback
traceback.print_stack()
self._file_items.clear() self._file_items.clear()
# Header removed - tab label provides context # Header removed - tab label provides context
@@ -781,41 +704,15 @@ class FileProgressList(QWidget):
def clear(self): def clear(self):
"""Clear all file items.""" """Clear all file items."""
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets # CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
_debug_log(f"[WIDGET_FIX] clear() called - count={self.list_widget.count()}")
for i in range(self.list_widget.count()): for i in range(self.list_widget.count()):
item = self.list_widget.item(i) item = self.list_widget.item(i)
if item: if item:
widget = self.list_widget.itemWidget(item) widget = self.list_widget.itemWidget(item)
if widget: if widget:
_debug_log(f"[WIDGET_FIX] Removing widget before clear() - widget={widget}, parent={widget.parent()}, isWindow()={widget.isWindow()}")
self.list_widget.removeItemWidget(item) self.list_widget.removeItemWidget(item)
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Widget became top-level window after removeItemWidget() before clear()!")
import traceback
traceback.print_stack()
self.list_widget.clear() self.list_widget.clear()
# Check widgets in _file_items dict after clear
for key, widget in list(self._file_items.items()):
if widget:
_debug_log(f"[WIDGET_FIX] Widget in _file_items after clear() - key={key}, widget={widget}, parent={widget.parent()}, isWindow()={widget.isWindow()}")
if widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Widget in _file_items is a top-level window after clear()! This is the bug!")
import traceback
traceback.print_stack()
self._file_items.clear() self._file_items.clear()
if self._summary_widget:
_debug_log(f"[WIDGET_FIX] Clearing summary_widget - widget={self._summary_widget}, parent={self._summary_widget.parent()}, isWindow()={self._summary_widget.isWindow()}")
if self._summary_widget.isWindow():
print(f"[WIDGET_FIX] ERROR: Summary widget is a top-level window in clear()!")
import traceback
traceback.print_stack()
self._summary_widget = None self._summary_widget = None
if self._transition_label:
_debug_log(f"[WIDGET_FIX] Clearing transition_label - widget={self._transition_label}, parent={self._transition_label.parent()}, isWindow()={self._transition_label.isWindow()}")
if self._transition_label.isWindow():
print(f"[WIDGET_FIX] ERROR: Transition label is a top-level window in clear()!")
import traceback
traceback.print_stack()
self._transition_label = None self._transition_label = None
self._last_phase = None self._last_phase = None
# Header removed - tab label provides context # Header removed - tab label provides context