mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 02:23:45 +02:00
Release v0.7.1 - Remote Manifest System, Stability Fixes
This commit is contained in:
@@ -17,11 +17,12 @@ from PySide6.QtGui import QFont
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE
|
||||
from ..utils import set_responsive_minimum
|
||||
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdditionalTasksScreen(QWidget):
|
||||
class AdditionalTasksScreen(ThreadLifecycleMixin, QWidget):
|
||||
"""Additional Tasks screen for automation and standalone tools."""
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Copy of ConfigureNewModlistScreen, adapted for existing modlists
|
||||
import warnings
|
||||
from PySide6.QtWidgets import *
|
||||
from PySide6.QtCore import *
|
||||
from PySide6.QtGui import *
|
||||
@@ -58,7 +59,6 @@ class ConfigureExistingModlistScreen(
|
||||
super().hideEvent(event)
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
try:
|
||||
self._vnv_controller.cleanup()
|
||||
@@ -67,7 +67,6 @@ class ConfigureExistingModlistScreen(
|
||||
pass
|
||||
if hasattr(self, 'file_progress_list'):
|
||||
self.file_progress_list.stop_cpu_tracking()
|
||||
self._park_all_threads()
|
||||
|
||||
def cancel_and_cleanup(self):
|
||||
"""Handle Cancel button - clean up processes and go back"""
|
||||
@@ -91,7 +90,7 @@ class ConfigureExistingModlistScreen(
|
||||
main_window.setMaximumSize(QSize(16777215, 16777215))
|
||||
set_responsive_minimum(main_window, min_width=960, min_height=420)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to set initial collapsed state: {e}")
|
||||
logger.warning(f"Failed to set initial collapsed state: {e}")
|
||||
|
||||
# Shortcut loading is handled by reset_screen_to_defaults() → refresh_modlist_list()
|
||||
# which fires via _debug_screen_change on every navigation to this screen.
|
||||
@@ -183,11 +182,13 @@ class ConfigureExistingModlistScreen(
|
||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||
return
|
||||
|
||||
for sig_name in ('progress_update', 'configuration_complete', 'error_occurred', 'steam_restart_needed'):
|
||||
try:
|
||||
getattr(self.config_thread, sig_name).disconnect()
|
||||
except (RuntimeError, TypeError, AttributeError):
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
for sig_name in ('progress_update', 'configuration_complete', 'error_occurred', 'steam_restart_needed'):
|
||||
try:
|
||||
getattr(self.config_thread, sig_name).disconnect()
|
||||
except (RuntimeError, TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Shortcut loading for ConfigureExistingModlistScreen (Mixin)."""
|
||||
from PySide6.QtCore import QThread, Signal, QObject
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class ConfigureExistingModlistShortcutsMixin:
|
||||
@@ -75,14 +76,16 @@ class ConfigureExistingModlistShortcutsMixin:
|
||||
if hasattr(self, '_park_thread'):
|
||||
self._park_thread(self._shortcut_loader, ["finished_signal", "error_signal"])
|
||||
else:
|
||||
try:
|
||||
self._shortcut_loader.finished_signal.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._shortcut_loader.error_signal.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
self._shortcut_loader.finished_signal.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._shortcut_loader.error_signal.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
if not hasattr(self, '_old_loaders'):
|
||||
self._old_loaders = []
|
||||
self._old_loaders.append(self._shortcut_loader)
|
||||
@@ -101,14 +104,13 @@ class ConfigureExistingModlistShortcutsMixin:
|
||||
def _on_shortcuts_loaded(self, shortcuts):
|
||||
"""Update UI when shortcuts are loaded"""
|
||||
self.mo2_shortcuts = shortcuts
|
||||
|
||||
# Update the dropdown
|
||||
|
||||
if hasattr(self, 'shortcut_combo'):
|
||||
self.shortcut_combo.clear()
|
||||
self.shortcut_combo.setEnabled(True)
|
||||
self.shortcut_combo.addItem("Please Select...")
|
||||
self.shortcut_map.clear()
|
||||
|
||||
|
||||
for shortcut in self.mo2_shortcuts:
|
||||
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
|
||||
self.shortcut_combo.addItem(display)
|
||||
|
||||
@@ -15,13 +15,33 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
"""Mixin providing workflow management for ConfigureExistingModlistScreen."""
|
||||
|
||||
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
|
||||
"""Detect special game type using the canonical ModlistHandler detection."""
|
||||
"""Detect game type for the verifier from ModOrganizer.ini."""
|
||||
try:
|
||||
from jackify.backend.handlers.modlist_handler import ModlistHandler
|
||||
return ModlistHandler().detect_special_game_type(install_dir) or 'skyrim'
|
||||
special = ModlistHandler().detect_special_game_type(install_dir)
|
||||
if special:
|
||||
return special
|
||||
except Exception as e:
|
||||
logger.warning("Game type detection failed, defaulting to skyrim: %s", e)
|
||||
return 'skyrim'
|
||||
logger.warning("Special game type detection failed: %s", e)
|
||||
|
||||
# detect_special_game_type only covers non-default games; read gameName= directly
|
||||
try:
|
||||
from pathlib import Path
|
||||
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
|
||||
if mo2_ini.exists():
|
||||
for raw_line in mo2_ini.read_text(errors='ignore').splitlines():
|
||||
line = raw_line.strip().lower()
|
||||
if line.startswith("gamename="):
|
||||
val = line[len("gamename="):]
|
||||
if "fallout 4" in val:
|
||||
return "fallout4"
|
||||
if "skyrim" in val:
|
||||
return "skyrim"
|
||||
except Exception as e:
|
||||
logger.warning("ModOrganizer.ini gameName read failed: %s", e)
|
||||
|
||||
logger.warning("Could not determine game type for %s, verifier will run generic checks only", install_dir)
|
||||
return 'unknown'
|
||||
|
||||
def validate_and_start_configure(self):
|
||||
# Reload config to pick up any settings changes made in Settings dialog
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
ConfigureNewModlistScreen for Jackify GUI
|
||||
"""
|
||||
import logging
|
||||
import warnings
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox, QMainWindow
|
||||
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject
|
||||
from PySide6.QtGui import QPixmap, QTextCursor
|
||||
@@ -126,12 +127,14 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr
|
||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
|
||||
@@ -93,7 +93,6 @@ class ConfigureNewModlistDialogsMixin:
|
||||
super().hideEvent(event)
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
try:
|
||||
self._vnv_controller.cleanup()
|
||||
@@ -103,7 +102,6 @@ class ConfigureNewModlistDialogsMixin:
|
||||
self._stop_focus_reclaim()
|
||||
if hasattr(self, 'file_progress_list'):
|
||||
self.file_progress_list.stop_cpu_tracking()
|
||||
self._park_all_threads()
|
||||
|
||||
def show_shortcut_conflict_dialog(self, conflicts):
|
||||
"""Show dialog to reuse an existing shortcut or choose a new name."""
|
||||
|
||||
@@ -601,9 +601,9 @@ class ConfigureNewModlistUISetupMixin:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking protontricks: {e}")
|
||||
logger.error(f"Error checking protontricks: {e}")
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
MessageService.warning(self, "Protontricks Check Failed",
|
||||
MessageService.warning(self, "Protontricks Check Failed",
|
||||
f"Unable to verify protontricks installation: {e}\n\n"
|
||||
"Continuing anyway, but some features may not work correctly.")
|
||||
return True # Continue anyway
|
||||
|
||||
@@ -4,6 +4,7 @@ from PySide6.QtCore import QThread, Signal
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import warnings
|
||||
from jackify.shared.resolution_utils import get_resolution_fallback
|
||||
from jackify.shared.errors import configuration_failed
|
||||
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
|
||||
@@ -262,12 +263,14 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
"""Safely release the automated prefix thread after it has finished."""
|
||||
if not hasattr(self, 'automated_prefix_thread') or self.automated_prefix_thread is None:
|
||||
return
|
||||
try:
|
||||
self.automated_prefix_thread.progress_update.disconnect()
|
||||
self.automated_prefix_thread.workflow_complete.disconnect()
|
||||
self.automated_prefix_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
self.automated_prefix_thread.progress_update.disconnect()
|
||||
self.automated_prefix_thread.workflow_complete.disconnect()
|
||||
self.automated_prefix_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
if self.automated_prefix_thread.isRunning():
|
||||
self.automated_prefix_thread.quit()
|
||||
self.automated_prefix_thread.wait(5000)
|
||||
|
||||
@@ -438,5 +438,3 @@ class ConfigureToolConfigScreen(ThreadLifecycleMixin, QWidget):
|
||||
except Exception as e:
|
||||
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
|
||||
|
||||
def cleanup_processes(self):
|
||||
self._park_all_threads()
|
||||
|
||||
@@ -30,6 +30,7 @@ from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
||||
from ..widgets.progress_indicator import OverallProgressIndicator
|
||||
from ..widgets.file_progress_list import FileProgressList
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -74,7 +75,7 @@ class MO2SetupWorker(QThread):
|
||||
self.setup_complete.emit(False, None, str(e))
|
||||
|
||||
|
||||
class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
class InstallMO2Screen(ThreadLifecycleMixin, ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
"""Standalone MO2 setup screen"""
|
||||
|
||||
resize_request = Signal(str)
|
||||
@@ -498,23 +499,12 @@ class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
self.go_back()
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Stop active MO2 worker and CPU tracking before screen/app shutdown."""
|
||||
self._stop_focus_reclaim()
|
||||
try:
|
||||
self.file_progress_list.stop_cpu_tracking()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.worker is not None:
|
||||
try:
|
||||
if self.worker.isRunning():
|
||||
self.worker.requestInterruption()
|
||||
self.worker.wait(10000)
|
||||
self.worker.deleteLater()
|
||||
except Exception:
|
||||
pass
|
||||
self.worker = None
|
||||
|
||||
def reset_screen_to_defaults(self):
|
||||
self.file_progress_list.clear()
|
||||
self.console.clear()
|
||||
|
||||
@@ -198,8 +198,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
|
||||
self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height)
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
print(f"DEBUG: Error calculating upper section height: {e}")
|
||||
pass
|
||||
logger.debug(f"Error calculating upper section height: {e}")
|
||||
|
||||
# Calculate heights immediately after forcing layout update
|
||||
# Prevents visible layout shift
|
||||
@@ -356,7 +355,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking protontricks: {e}")
|
||||
logger.error(f"Error checking protontricks: {e}")
|
||||
MessageService.warning(self, "Protontricks Check Failed",
|
||||
f"Unable to verify protontricks installation: {e}\n\n"
|
||||
"Continuing anyway, but some features may not work correctly.")
|
||||
@@ -424,7 +423,6 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
|
||||
super().hideEvent(event)
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
self._stop_clf3_decompress_pulse()
|
||||
|
||||
fpl = getattr(self, 'file_progress_list', None)
|
||||
@@ -440,74 +438,23 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
|
||||
|
||||
self._stop_focus_reclaim()
|
||||
|
||||
# Disconnect all thread signals before any stopping - prevents callbacks to
|
||||
# a dying widget if threads emit between now and actual termination.
|
||||
self._park_all_threads()
|
||||
|
||||
def _stop_thread(attr_name: str, cancel_method: Optional[str] = None, cooperative_ms: int = 5000, force_ms: int = 10000):
|
||||
thread = getattr(self, attr_name, None)
|
||||
if thread is None:
|
||||
return
|
||||
# install_thread needs cancel() to kill the child subprocess, not just
|
||||
# signal disconnection. The extended wait gives the engine time to flush.
|
||||
thread = getattr(self, 'install_thread', None)
|
||||
if thread is not None:
|
||||
try:
|
||||
running = thread.isRunning()
|
||||
except RuntimeError:
|
||||
setattr(self, attr_name, None)
|
||||
return
|
||||
|
||||
if not running:
|
||||
setattr(self, attr_name, None)
|
||||
return
|
||||
|
||||
logger.debug(f"Stopping {attr_name}")
|
||||
|
||||
if cancel_method and hasattr(thread, cancel_method):
|
||||
running = False
|
||||
if running:
|
||||
try:
|
||||
getattr(thread, cancel_method)()
|
||||
thread.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
thread.requestInterruption()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
thread.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if thread.wait(cooperative_ms):
|
||||
setattr(self, attr_name, None)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.warning(f"WARNING: {attr_name} did not stop in {cooperative_ms}ms, waiting for forced shutdown window")
|
||||
try:
|
||||
if cancel_method and hasattr(thread, cancel_method):
|
||||
getattr(thread, cancel_method)()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if not thread.wait(force_ms):
|
||||
logger.error(f"ERROR: {attr_name} still running after forced shutdown window")
|
||||
except Exception:
|
||||
pass
|
||||
setattr(self, attr_name, None)
|
||||
|
||||
# Always stop installer thread first; it needs cancel() not terminate().
|
||||
_stop_thread('install_thread', cancel_method='cancel', cooperative_ms=15000, force_ms=10000)
|
||||
|
||||
# Stop any remaining QThread instances on this object, regardless of attribute name.
|
||||
from PySide6.QtCore import QThread
|
||||
for attr_name, value in list(vars(self).items()):
|
||||
if attr_name == 'install_thread':
|
||||
continue
|
||||
try:
|
||||
if isinstance(value, QThread):
|
||||
_stop_thread(attr_name)
|
||||
except Exception:
|
||||
pass
|
||||
if not thread.wait(15000):
|
||||
logger.warning("install_thread did not stop in 15s")
|
||||
thread.wait(10000)
|
||||
self.install_thread = None
|
||||
|
||||
def cancel_installation(self):
|
||||
"""Cancel the currently running installation"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Configuration phase workflow for InstallModlistScreen (Mixin)."""
|
||||
import warnings
|
||||
from PySide6.QtWidgets import QMessageBox, QProgressDialog
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
||||
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
||||
@@ -178,6 +179,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
'time_taken': time_str,
|
||||
'game_name': game_name,
|
||||
'enb_detected': enb_detected,
|
||||
'readme_url': getattr(self, '_readme_url', None),
|
||||
},
|
||||
)
|
||||
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
|
||||
@@ -233,12 +235,14 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
@@ -525,12 +529,14 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
# Clean up old thread if exists and wait for it to finish
|
||||
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
||||
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass # Ignore errors if already disconnected
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass # Ignore errors if already disconnected
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
self.config_thread.wait(5000) # Wait up to 5 seconds
|
||||
@@ -618,7 +624,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
|
||||
except Exception as e:
|
||||
error_details = f"Error in configuration: {e}\nTraceback: {traceback.format_exc()}"
|
||||
self.progress_update.emit(f"DEBUG: {error_details}")
|
||||
self.progress_update.emit(f"ERROR: {error_details}")
|
||||
self.error_occurred.emit(str(e))
|
||||
|
||||
return ConfigThread(context, is_steamdeck, detect_game_type_func, parent=self)
|
||||
|
||||
@@ -307,7 +307,7 @@ class InstallerThread(QThread):
|
||||
"""Emit periodic 'finalising' updates to Show Details when stdout goes silent.
|
||||
|
||||
Fires once when silence exceeds THRESHOLD, then repeats every REPEAT seconds
|
||||
of continued silence — so extended waits remain visible to the user.
|
||||
of continued silence - so extended waits remain visible to the user.
|
||||
Samples /proc/<pid>/io to show write throughput, giving the user concrete
|
||||
evidence that extraction is progressing even when CLF3 emits no output.
|
||||
Resets when real output arrives so a new silence period can trigger it again.
|
||||
@@ -360,7 +360,7 @@ class InstallerThread(QThread):
|
||||
JSON progress events (keyed on "type") and plain human-readable text both arrive
|
||||
on stdout. Manual download events (keyed on "event") also appear on stdout.
|
||||
|
||||
Extraction dispatch counters ("Extracting: N/M") are dropped — named per-archive
|
||||
Extraction dispatch counters ("Extracting: N/M") are dropped - named per-archive
|
||||
completion lines already cover this information.
|
||||
Directive counters ("Processing: N/M") are buffered; only the final value is
|
||||
emitted when the next non-counter line arrives, avoiding a 1315-line flood.
|
||||
@@ -391,7 +391,7 @@ class InstallerThread(QThread):
|
||||
self.progress_updated.emit(state)
|
||||
msg = state.message
|
||||
if msg.startswith('Extracting ') and '(' in msg and state.phase_name == "Extracting":
|
||||
# Relabel as "Queuing" — the N/M counter tracks archive dispatch
|
||||
# Relabel as "Queuing" - the N/M counter tracks archive dispatch
|
||||
# to worker threads, not completion of decompression.
|
||||
self.output_received.emit(msg.replace('Extracting ', 'Queuing ', 1) + '\n')
|
||||
continue
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
from PySide6.QtWidgets import QMessageBox, QApplication, QDialog
|
||||
from jackify.frontends.gui.utils import browse_directory, browse_file
|
||||
from PySide6.QtCore import QTimer, Qt
|
||||
from PySide6.QtGui import QFontMetrics
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -160,7 +161,11 @@ class ModlistSelectionMixin:
|
||||
|
||||
if self._gallery_dlg.exec() == QDialog.Accepted and self._gallery_dlg.selected_metadata:
|
||||
metadata = self._gallery_dlg.selected_metadata
|
||||
self.modlist_btn.setText(metadata.title)
|
||||
metrics = QFontMetrics(self.modlist_btn.font())
|
||||
available_width = self.modlist_btn.width() - 24 # padding allowance
|
||||
elided_title = metrics.elidedText(metadata.title, Qt.ElideRight, available_width)
|
||||
self.modlist_btn.setText(elided_title)
|
||||
self.modlist_btn.setToolTip(metadata.title)
|
||||
self.selected_modlist_info = {
|
||||
'machine_url': metadata.namespacedName,
|
||||
'title': metadata.title,
|
||||
|
||||
@@ -162,6 +162,7 @@ class InstallModlistUISetupMixin:
|
||||
# --- Modlist Selection ---
|
||||
self.modlist_btn = QPushButton("Select Modlist")
|
||||
self.modlist_btn.setMinimumWidth(300)
|
||||
self.modlist_btn.setMaximumWidth(300)
|
||||
self.modlist_btn.clicked.connect(self.open_modlist_dialog)
|
||||
self.modlist_btn.setEnabled(False)
|
||||
online_layout.addWidget(QLabel("Game Type:"))
|
||||
|
||||
@@ -6,6 +6,12 @@ import shutil
|
||||
import time
|
||||
|
||||
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
|
||||
from jackify.backend.services.update_detection import (
|
||||
evaluate_update_candidate as _svc_evaluate,
|
||||
find_existing_shortcut_appid as _svc_find_appid,
|
||||
normalize_version_token,
|
||||
normalize_modlist_name,
|
||||
)
|
||||
from .install_modlist_output_mixin import InstallModlistOutputMixin
|
||||
from .install_modlist_workflow_execution import InstallWorkflowExecutionMixin
|
||||
|
||||
@@ -15,27 +21,16 @@ logger = logging.getLogger(__name__)
|
||||
class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMixin):
|
||||
"""Mixin providing installation workflow methods for InstallModlistScreen."""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_version_token(value: str | None) -> str | None:
|
||||
"""Return a normalized version token for lightweight equality checks."""
|
||||
if value is None:
|
||||
return None
|
||||
token = str(value).strip()
|
||||
if not token:
|
||||
return None
|
||||
token = token.lstrip("vV")
|
||||
return token.lower()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_modlist_name(value: str | None) -> str:
|
||||
return " ".join((value or "").strip().lower().split())
|
||||
# normalize_version_token and normalize_modlist_name are imported from update_detection service
|
||||
_normalize_version_token = staticmethod(normalize_version_token)
|
||||
_normalize_modlist_name = staticmethod(normalize_modlist_name)
|
||||
|
||||
def _get_requested_modlist_version(self, install_mode: str) -> str | None:
|
||||
"""Return selected modlist version from gallery metadata when available."""
|
||||
if install_mode != "online":
|
||||
return None
|
||||
info = getattr(self, "selected_modlist_info", None) or {}
|
||||
return self._normalize_version_token(info.get("version"))
|
||||
return normalize_version_token(info.get("version"))
|
||||
|
||||
def _evaluate_update_candidate(
|
||||
self,
|
||||
@@ -44,52 +39,8 @@ class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMi
|
||||
install_mode: str,
|
||||
existing_appid: str | None,
|
||||
) -> tuple[bool, dict]:
|
||||
"""
|
||||
Decide whether update-mode prompt should be shown.
|
||||
|
||||
Policy:
|
||||
- Require existing shortcut AppID and jackify_meta.json.
|
||||
- Require modlist identity match (requested name == installed meta name).
|
||||
- Version relation is informational:
|
||||
- `different` when both requested/installed versions are available and differ.
|
||||
- `same` when both are available and equal.
|
||||
- `unknown` when either side is missing.
|
||||
"""
|
||||
from jackify.backend.utils.modlist_meta import read_modlist_meta
|
||||
|
||||
result = {
|
||||
"eligible": False,
|
||||
"reason": "unknown",
|
||||
"requested_version": None,
|
||||
"installed_version": None,
|
||||
"version_relation": "unknown",
|
||||
"installed_name": None,
|
||||
}
|
||||
if not existing_appid:
|
||||
result["reason"] = "missing_shortcut_appid"
|
||||
return False, result
|
||||
|
||||
meta = read_modlist_meta(install_dir)
|
||||
if not meta:
|
||||
result["reason"] = "missing_meta"
|
||||
return False, result
|
||||
|
||||
installed_name = (meta.get("modlist_name") or "").strip()
|
||||
result["installed_name"] = installed_name
|
||||
if self._normalize_modlist_name(installed_name) != self._normalize_modlist_name(modlist_name):
|
||||
result["reason"] = "modlist_name_mismatch"
|
||||
return False, result
|
||||
|
||||
requested_version = self._get_requested_modlist_version(install_mode)
|
||||
installed_version = self._normalize_version_token(meta.get("modlist_version"))
|
||||
result["requested_version"] = requested_version
|
||||
result["installed_version"] = installed_version
|
||||
if requested_version and installed_version:
|
||||
result["version_relation"] = "same" if requested_version == installed_version else "different"
|
||||
|
||||
result["eligible"] = True
|
||||
result["reason"] = "eligible"
|
||||
return True, result
|
||||
return _svc_evaluate(modlist_name, install_dir, existing_appid, requested_version)
|
||||
|
||||
def _resolve_modorganizer_ini_path(self, install_dir: str) -> str | None:
|
||||
"""Return ModOrganizer.ini path for standard/special layouts."""
|
||||
@@ -276,38 +227,7 @@ class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMi
|
||||
|
||||
def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None:
|
||||
"""Return existing Steam shortcut AppID for this install dir/name when present."""
|
||||
try:
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
|
||||
|
||||
install_real = os.path.realpath(install_dir)
|
||||
candidate_exes = [
|
||||
os.path.join(install_real, "ModOrganizer.exe"),
|
||||
os.path.join(install_real, "files", "ModOrganizer.exe"), # Somnium layout
|
||||
]
|
||||
|
||||
for exe_path in candidate_exes:
|
||||
if not os.path.exists(exe_path):
|
||||
continue
|
||||
appid = shortcut_handler.get_appid_from_vdf(modlist_name, exe_path)
|
||||
if appid:
|
||||
return appid
|
||||
|
||||
# Fallback: match by name + start dir from shortcuts.vdf even if exe moved
|
||||
for shortcut in shortcut_handler.find_shortcuts_by_exe("ModOrganizer.exe"):
|
||||
if (
|
||||
(shortcut.get("AppName", "").strip() == modlist_name.strip())
|
||||
and os.path.realpath(shortcut.get("StartDir", "")) == install_real
|
||||
):
|
||||
raw_appid = shortcut.get("appid")
|
||||
if raw_appid is not None:
|
||||
return str(int(raw_appid) & 0xFFFFFFFF)
|
||||
except Exception as e:
|
||||
logger.warning("Update detection: failed shortcut lookup: %s", e)
|
||||
return None
|
||||
return _svc_find_appid(modlist_name, install_dir)
|
||||
|
||||
def _prompt_update_or_new_install(
|
||||
self,
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
|
||||
from .install_modlist_installer_thread import InstallerThread
|
||||
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
|
||||
from jackify.backend.models.game_types import GAME_DISPLAY_NAMES, GAME_NAME_TO_TYPE
|
||||
from jackify.shared.errors import install_dir_create_failed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -307,57 +308,20 @@ class InstallWorkflowExecutionMixin:
|
||||
if result:
|
||||
if isinstance(result, tuple):
|
||||
game_type, raw_game_type = result
|
||||
# Get display name for the game
|
||||
display_names = {
|
||||
'skyrim': 'Skyrim',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
}
|
||||
if game_type == 'unknown' and raw_game_type:
|
||||
game_name = raw_game_type
|
||||
else:
|
||||
game_name = display_names.get(game_type, game_type)
|
||||
game_name = GAME_DISPLAY_NAMES.get(game_type, game_type)
|
||||
else:
|
||||
game_type = result
|
||||
display_names = {
|
||||
'skyrim': 'Skyrim',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
}
|
||||
game_name = display_names.get(game_type, game_type)
|
||||
game_name = GAME_DISPLAY_NAMES.get(game_type, game_type)
|
||||
else:
|
||||
# For online modlists, try to get game type from selected modlist
|
||||
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
|
||||
readme_url = self.selected_modlist_info.get('readme_url')
|
||||
game_name = self.selected_modlist_info.get('game', '')
|
||||
logger.debug(f"Detected game_name from selected_modlist_info: '{game_name}'")
|
||||
|
||||
# Map game name to game type
|
||||
game_mapping = {
|
||||
'skyrim special edition': 'skyrim',
|
||||
'skyrim': 'skyrim',
|
||||
'fallout 4': 'fallout4',
|
||||
'fallout new vegas': 'falloutnv',
|
||||
'oblivion': 'oblivion',
|
||||
'starfield': 'starfield',
|
||||
'oblivion_remastered': 'oblivion_remastered',
|
||||
'oblivion remastered': 'oblivion_remastered',
|
||||
'enderal': 'enderal',
|
||||
'enderal special edition': 'enderal',
|
||||
'skyrim vr': 'skyrimvr',
|
||||
'fallout 4 vr': 'fallout4vr',
|
||||
'cyberpunk 2077': 'cp2077',
|
||||
"baldur's gate 3": 'bg3',
|
||||
}
|
||||
game_type = game_mapping.get(game_name.lower())
|
||||
game_type = GAME_NAME_TO_TYPE.get(game_name.lower())
|
||||
logger.debug(f"Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
||||
if not game_type:
|
||||
game_type = 'unknown'
|
||||
@@ -504,7 +468,7 @@ class InstallWorkflowExecutionMixin:
|
||||
readme_url = readme_url.replace("raw.githubusercontent.com", "github.com")
|
||||
readme_url = readme_url.replace("/main/", "/blob/main/")
|
||||
readme_url = readme_url.replace("/master/", "/blob/master/")
|
||||
logger.info(f"Opening modlist readme: {readme_url}")
|
||||
logger.info("Opening modlist readme: %s", readme_url)
|
||||
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
|
||||
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
|
||||
subprocess.Popen(["xdg-open", readme_url], env=clean_env, start_new_session=True)
|
||||
@@ -512,6 +476,7 @@ class InstallWorkflowExecutionMixin:
|
||||
"Modlist readme opened in your browser. "
|
||||
"Check it for any manual post-install steps before launching the game."
|
||||
)
|
||||
self._readme_url = readme_url or None
|
||||
|
||||
logger.debug(f"Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}")
|
||||
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info)
|
||||
@@ -620,8 +585,9 @@ class InstallWorkflowExecutionMixin:
|
||||
concurrent_limit = max(1, min(5, concurrent_limit))
|
||||
|
||||
self._safe_append_text(
|
||||
f"\n[Manual Download Required] {count} file(s) need manual download.\n"
|
||||
f"Opening download dialog - check your taskbar if it does not appear in front.\n"
|
||||
f"\n[Manual Download Required] {count} file(s) need manual download "
|
||||
f"(rate limit, access error, or non-premium).\n"
|
||||
f"Opening download dialog - it will appear in front momentarily.\n"
|
||||
)
|
||||
logger.info(
|
||||
f"[MDL-1006] Manual download protocol initialized | count={count} "
|
||||
@@ -663,3 +629,5 @@ class InstallWorkflowExecutionMixin:
|
||||
|
||||
if not self._manual_dl_dialog.isVisible():
|
||||
self._manual_dl_dialog.show()
|
||||
self._manual_dl_dialog.raise_()
|
||||
self._manual_dl_dialog.activateWindow()
|
||||
|
||||
@@ -233,8 +233,8 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking protontricks: {e}")
|
||||
MessageService.warning(self, "Protontricks Check Failed",
|
||||
logger.error(f"Error checking protontricks: {e}")
|
||||
MessageService.warning(self, "Protontricks Check Failed",
|
||||
f"Unable to verify protontricks installation: {e}\n\n"
|
||||
"Continuing anyway, but some features may not work correctly.")
|
||||
return True # Continue anyway
|
||||
@@ -305,14 +305,10 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
|
||||
dlg.exec()
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
# Disconnect all signals first - prevents callbacks to a dying widget.
|
||||
self._park_all_threads()
|
||||
|
||||
# install_thread gets a cooperative cancel signal on top of the park.
|
||||
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
|
||||
if hasattr(self, 'install_thread') and self.install_thread:
|
||||
try:
|
||||
self.install_thread.cancel()
|
||||
if self.install_thread.isRunning():
|
||||
self.install_thread.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QLineEd
|
||||
from PySide6.QtCore import Qt, QTimer, QSize
|
||||
from PySide6.QtGui import QFont
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
|
||||
from jackify.frontends.gui.services.message_service import open_url
|
||||
from jackify.backend.handlers.wabbajack_parser import WabbajackParser
|
||||
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
|
||||
|
||||
@@ -110,7 +111,8 @@ class TTWUISetupMixin:
|
||||
)
|
||||
instruction_text.setWordWrap(True)
|
||||
instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;")
|
||||
instruction_text.setOpenExternalLinks(True)
|
||||
instruction_text.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
instruction_text.linkActivated.connect(open_url)
|
||||
user_config_vbox.addWidget(instruction_text)
|
||||
|
||||
# --- Compact Form Grid for inputs (align with other screens) ---
|
||||
|
||||
@@ -91,6 +91,52 @@ class InstallVerifierMixin:
|
||||
except Exception as e:
|
||||
logger.warning("JContainers fix check failed (non-fatal): %s", e)
|
||||
|
||||
def _apply_problem_mods_disable(
|
||||
self, install_dir: str, game_type: str, success_params: dict, appid: str = ""
|
||||
) -> None:
|
||||
"""Disable known-problematic mods and apply prefix fixes across all profiles."""
|
||||
try:
|
||||
from jackify.backend.services.problem_mods_service import (
|
||||
disable_problem_mods,
|
||||
create_prefix_dirs,
|
||||
get_enabled_mods,
|
||||
)
|
||||
|
||||
install_path = Path(install_dir)
|
||||
all_disabled: list = []
|
||||
all_enabled_mods: set = set()
|
||||
for modlist_txt in install_path.glob("profiles/*/modlist.txt"):
|
||||
disabled = disable_problem_mods(modlist_txt, game_type)
|
||||
for name in disabled:
|
||||
if name not in all_disabled:
|
||||
all_disabled.append(name)
|
||||
all_enabled_mods |= get_enabled_mods(modlist_txt)
|
||||
|
||||
if all_disabled:
|
||||
logger.info(
|
||||
"Disabled %d problem mod(s) for %s (%s): %s",
|
||||
len(all_disabled),
|
||||
success_params.get("modlist_name", ""),
|
||||
game_type,
|
||||
", ".join(all_disabled),
|
||||
)
|
||||
success_params["disabled_problem_mods"] = all_disabled
|
||||
|
||||
resolved_appid = str(appid or self._get_appid_for_install_dir(install_dir) or "")
|
||||
pfx = _resolve_pfx_for_appid(resolved_appid) if resolved_appid else None
|
||||
if pfx and all_enabled_mods:
|
||||
created = create_prefix_dirs(pfx, game_type, all_enabled_mods)
|
||||
if created:
|
||||
logger.info(
|
||||
"Created %d prefix dir(s) for %s (%s): %s",
|
||||
len(created),
|
||||
success_params.get("modlist_name", ""),
|
||||
game_type,
|
||||
", ".join(created),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Problem mods fix check failed (non-fatal): %s", e)
|
||||
|
||||
def _run_verifier_then_show_success(
|
||||
self,
|
||||
install_dir: str,
|
||||
@@ -105,6 +151,7 @@ class InstallVerifierMixin:
|
||||
success_params keys: modlist_name, workflow_type, time_taken, game_name, enb_detected
|
||||
"""
|
||||
self._maybe_apply_jcontainers_fix(install_dir, game_type)
|
||||
self._apply_problem_mods_disable(install_dir, game_type, success_params, appid)
|
||||
if hasattr(self, "progress_indicator"):
|
||||
self.progress_indicator.set_status("Verifying installation...", 100)
|
||||
if hasattr(self, "file_progress_list"):
|
||||
@@ -170,6 +217,8 @@ class InstallVerifierMixin:
|
||||
time_taken=params["time_taken"],
|
||||
game_name=params.get("game_name"),
|
||||
verification_results=verification_results,
|
||||
disabled_problem_mods=params.get("disabled_problem_mods"),
|
||||
readme_url=params.get("readme_url"),
|
||||
parent=self,
|
||||
)
|
||||
dlg.show()
|
||||
|
||||
@@ -4,6 +4,7 @@ Enhanced Modlist Gallery Screen for Jackify GUI.
|
||||
Provides visual browsing, filtering, and selection of modlists using
|
||||
rich metadata from jackify-engine.
|
||||
"""
|
||||
import warnings
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QLineEdit, QComboBox, QCheckBox, QScrollArea, QGridLayout,
|
||||
@@ -255,23 +256,27 @@ class ModlistGalleryDialog(ModlistGalleryFiltersMixin, ModlistGalleryLoadingMixi
|
||||
if timer is not None:
|
||||
timer.stop()
|
||||
|
||||
# Kill any in-progress engine subprocess so the loader thread exits
|
||||
# naturally and releases the engine call lock.
|
||||
if hasattr(self, 'gallery_service'):
|
||||
self.gallery_service.cancel()
|
||||
|
||||
for attr in ('_loader_thread', '_validation_thread'):
|
||||
thread = getattr(self, attr, None)
|
||||
if thread is None:
|
||||
continue
|
||||
# Disconnect all signals before terminating - prevents callbacks into
|
||||
# a partially-destroyed dialog
|
||||
try:
|
||||
thread.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
thread.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
if thread.isRunning():
|
||||
# terminate() is required here: these threads run plain Python code
|
||||
# with no Qt event loop, so quit() is a no-op and wait() alone
|
||||
# would time out, leaving the thread running when the C++ QThread
|
||||
# object is destroyed (which aborts the process).
|
||||
thread.terminate()
|
||||
thread.wait(3000)
|
||||
# Give the thread time to exit naturally after cancel().
|
||||
# Only fall back to terminate() if it is still stuck after that.
|
||||
if not thread.wait(2000):
|
||||
thread.terminate()
|
||||
thread.wait(1000)
|
||||
|
||||
# Abort any pending image network requests
|
||||
if hasattr(self, 'image_manager'):
|
||||
|
||||
@@ -28,6 +28,7 @@ from PySide6.QtGui import QFont, QPalette, QColor, QPixmap
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE
|
||||
from ..utils import set_responsive_minimum
|
||||
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
# Constants
|
||||
DEBUG_BORDERS = False
|
||||
@@ -35,7 +36,7 @@ DEBUG_BORDERS = False
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModlistTasksScreen(QWidget):
|
||||
class ModlistTasksScreen(ThreadLifecycleMixin, QWidget):
|
||||
"""
|
||||
Migrated Modlist Tasks screen that uses backend services directly.
|
||||
|
||||
@@ -233,5 +234,4 @@ class ModlistTasksScreen(QWidget):
|
||||
pass
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources when the screen is closed"""
|
||||
pass
|
||||
self._park_all_threads()
|
||||
@@ -8,6 +8,7 @@ the cards are rebuilt and version checks restart.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
@@ -74,6 +75,22 @@ class _ToolActionThread(QThread):
|
||||
self.finished_signal.emit(self._tool_id, ok, msg)
|
||||
|
||||
|
||||
class _ArchiveInstallThread(QThread):
|
||||
finished_signal = Signal(str, bool, str) # tool_id, success, message
|
||||
|
||||
def __init__(self, tool_id: str, archive_path: Path):
|
||||
super().__init__()
|
||||
self._tool_id = tool_id
|
||||
self._archive_path = archive_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
ok, msg = ToolRegistry().install_from_archive(self._tool_id, self._archive_path)
|
||||
except Exception as e:
|
||||
ok, msg = False, str(e)
|
||||
self.finished_signal.emit(self._tool_id, ok, msg)
|
||||
|
||||
|
||||
class _ManifestFetchThread(QThread):
|
||||
manifest_ready = Signal(list) # List[ToolDefinition]
|
||||
|
||||
@@ -258,7 +275,7 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
|
||||
def _on_manifest_ready(self, definitions: List[ToolDefinition]):
|
||||
current_ids = set(self._cards.keys())
|
||||
new_ids = {d.tool_id for d in definitions}
|
||||
new_ids = {d.tool_id for d in definitions if not d.hidden}
|
||||
apply_remote_manifest(definitions)
|
||||
if current_ids != new_ids:
|
||||
if self._version_thread and self._version_thread.isRunning():
|
||||
@@ -336,6 +353,13 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
def _on_action_finished(self, tool_id: str, success: bool, message: str):
|
||||
self._action_thread = None
|
||||
card = self._cards.get(tool_id)
|
||||
|
||||
if not success and message.startswith("NEXUS_MANUAL_REQUIRED:"):
|
||||
if card:
|
||||
card.set_busy(False)
|
||||
self._start_nexus_manual_install(tool_id, message[len("NEXUS_MANUAL_REQUIRED:"):])
|
||||
return
|
||||
|
||||
if success:
|
||||
status = ToolRegistry().get_status(tool_id)
|
||||
if status and status.installed and card:
|
||||
@@ -351,6 +375,23 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
card.set_busy(False)
|
||||
MessageService.warning(self, "Failed", message)
|
||||
|
||||
def _start_nexus_manual_install(self, tool_id: str, nexus_url: str) -> None:
|
||||
from jackify.frontends.gui.dialogs.nexus_manual_install_dialog import NexusManualInstallDialog
|
||||
defn = next((d for d in get_effective_definitions() if d.tool_id == tool_id), None)
|
||||
display_name = defn.display_name if defn else tool_id
|
||||
dlg = NexusManualInstallDialog(tool_id, display_name, nexus_url, parent=self)
|
||||
if dlg.exec() != NexusManualInstallDialog.Accepted:
|
||||
return
|
||||
archive = dlg.selected_archive
|
||||
if not archive:
|
||||
return
|
||||
card = self._cards.get(tool_id)
|
||||
if card:
|
||||
card.set_busy(True, "Installing...")
|
||||
self._action_thread = _ArchiveInstallThread(tool_id, archive)
|
||||
self._action_thread.finished_signal.connect(self._on_action_finished)
|
||||
self._action_thread.start()
|
||||
|
||||
def _on_update_all(self):
|
||||
updates = [tid for tid, card in self._cards.items()
|
||||
if card._status.installed and card._status.update_available]
|
||||
@@ -389,6 +430,9 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
status = ToolRegistry().get_status(tool_id)
|
||||
if not status:
|
||||
return
|
||||
if not status.definition.github_repo:
|
||||
MessageService.warning(self, "Change Version", "Version selection is only available for GitHub-hosted tools.")
|
||||
return
|
||||
if card:
|
||||
card.set_busy(True, "Fetching releases...")
|
||||
self._release_thread = _ReleaseFetchThread(tool_id, status.definition.github_repo)
|
||||
@@ -465,5 +509,3 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
||||
|
||||
def cleanup_processes(self):
|
||||
self._park_all_threads()
|
||||
|
||||
@@ -6,7 +6,6 @@ Engines show Set Active / Active badge; tools with can_launch show Launch.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
@@ -16,7 +15,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from jackify.backend.services.tool_registry import ToolRegistry, ToolStatus, set_active_engine_id
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.frontends.gui.services.message_service import MessageService, open_url
|
||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,6 +27,12 @@ _C_SET_ACTIVE = "#4a5568"
|
||||
_C_BACK = "#4a5568"
|
||||
_C_DISABLED = "#333"
|
||||
|
||||
_STYLE_BTN_INVISIBLE = (
|
||||
"QPushButton { background: transparent; border: none; color: transparent; "
|
||||
"font-size: 11px; font-weight: bold; padding: 4px 8px; min-width: 90px; }"
|
||||
"QPushButton:hover { background: transparent; }"
|
||||
)
|
||||
|
||||
_BADGE_NOT_INSTALLED = ("#555", "#ccc")
|
||||
_BADGE_UP_TO_DATE = ("#1a3545", "#5fb8c8")
|
||||
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
|
||||
@@ -78,7 +83,18 @@ class ToolCard(QFrame):
|
||||
|
||||
info_col = QVBoxLayout()
|
||||
info_col.setSpacing(2)
|
||||
self._name_label = QLabel(f"<b>{status.definition.display_name}</b>")
|
||||
url = status.definition.upstream_url
|
||||
if url:
|
||||
name_html = (
|
||||
f'<a href="{url}" style="color: #e0e0e0; text-decoration: none; font-weight: bold;">'
|
||||
f'{status.definition.display_name}</a>'
|
||||
)
|
||||
else:
|
||||
name_html = f"<b>{status.definition.display_name}</b>"
|
||||
self._name_label = QLabel(name_html)
|
||||
self._name_label.setTextFormat(Qt.RichText)
|
||||
self._name_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
self._name_label.linkActivated.connect(self._open_url)
|
||||
self._name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
|
||||
info_col.addWidget(self._name_label)
|
||||
desc_label = QLabel(status.definition.description)
|
||||
@@ -118,7 +134,6 @@ class ToolCard(QFrame):
|
||||
btn_col.addWidget(self._btn_primary)
|
||||
self._btn_update = QPushButton("Update")
|
||||
self._btn_update.setFixedWidth(100)
|
||||
self._btn_update.setVisible(False)
|
||||
self._btn_update.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "update"))
|
||||
btn_col.addWidget(self._btn_update)
|
||||
self._btn_more = QPushButton("...")
|
||||
@@ -150,7 +165,10 @@ class ToolCard(QFrame):
|
||||
self._btn_primary.setText(self._busy_label or "Working...")
|
||||
self._btn_primary.setEnabled(False)
|
||||
self._btn_primary.setVisible(True)
|
||||
self._btn_update.setVisible(False)
|
||||
self._btn_update.setStyleSheet(
|
||||
_STYLE_BTN_INVISIBLE
|
||||
)
|
||||
self._btn_update.setEnabled(False)
|
||||
self._btn_more.setEnabled(False)
|
||||
return
|
||||
|
||||
@@ -170,9 +188,7 @@ class ToolCard(QFrame):
|
||||
|
||||
iv = self._status.installed_version or "-"
|
||||
lv = self._status.latest_version or "checking..."
|
||||
self._version_label.setText(
|
||||
f"Installed: {iv}\nLatest: {lv}" if installed else f"Latest: {lv}"
|
||||
)
|
||||
self._version_label.setText(f"Installed: {iv}\nLatest: {lv}")
|
||||
|
||||
if not installed:
|
||||
self._btn_primary.setText("Install")
|
||||
@@ -197,9 +213,14 @@ class ToolCard(QFrame):
|
||||
else:
|
||||
self._btn_primary.setVisible(False)
|
||||
|
||||
self._btn_update.setVisible(installed and update_avail and not self._busy)
|
||||
if installed and update_avail:
|
||||
if installed and update_avail and not self._busy:
|
||||
self._btn_update.setStyleSheet(btn_style(_C_UPDATE))
|
||||
self._btn_update.setEnabled(True)
|
||||
else:
|
||||
self._btn_update.setStyleSheet(
|
||||
_STYLE_BTN_INVISIBLE
|
||||
)
|
||||
self._btn_update.setEnabled(False)
|
||||
self._btn_more.setEnabled(not self._busy)
|
||||
|
||||
def set_latest_version(self, tag: str) -> bool:
|
||||
@@ -289,6 +310,9 @@ class ToolCard(QFrame):
|
||||
else:
|
||||
self._launch()
|
||||
|
||||
def _open_url(self, url: str):
|
||||
open_url(url)
|
||||
|
||||
def _launch(self):
|
||||
binary = ToolRegistry().get_binary_path(self._tool_id)
|
||||
if not binary:
|
||||
@@ -313,13 +337,18 @@ class ToolCard(QFrame):
|
||||
"QMenu::item:disabled { color: #555; }"
|
||||
)
|
||||
defn = self._status.definition
|
||||
upstream_action = menu.addAction("Open Website")
|
||||
upstream_action.setEnabled(bool(defn.upstream_url))
|
||||
menu.addSeparator()
|
||||
downgrade_action = menu.addAction("Change Version")
|
||||
downgrade_action.setEnabled(self._status.can_downgrade and not self._busy)
|
||||
uninstall_action = menu.addAction("Uninstall")
|
||||
uninstall_action.setEnabled(defn.can_uninstall and self._status.installed and not self._busy)
|
||||
|
||||
chosen = menu.exec(self._btn_more.mapToGlobal(self._btn_more.rect().bottomLeft()))
|
||||
if chosen == downgrade_action and downgrade_action.isEnabled():
|
||||
if chosen == upstream_action and defn.upstream_url:
|
||||
self._open_url(defn.upstream_url)
|
||||
elif chosen == downgrade_action and downgrade_action.isEnabled():
|
||||
self.action_requested.emit(self._tool_id, "downgrade")
|
||||
elif chosen == uninstall_action and uninstall_action.isEnabled():
|
||||
QTimer.singleShot(0, lambda: self._prompt_uninstall(defn.display_name))
|
||||
|
||||
@@ -30,6 +30,7 @@ from ..utils import set_responsive_minimum, browse_directory
|
||||
from ..widgets.file_progress_list import FileProgressList
|
||||
from ..widgets.progress_indicator import OverallProgressIndicator
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -89,7 +90,7 @@ class WabbajackInstallerWorker(QThread):
|
||||
self.installation_complete.emit(False, error_msg or "Installation failed", "", "", "")
|
||||
|
||||
|
||||
class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
class WabbajackInstallerScreen(ThreadLifecycleMixin, ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
"""Wabbajack installer GUI screen following standard Jackify layout"""
|
||||
|
||||
resize_request = Signal(str)
|
||||
@@ -389,14 +390,13 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
# Get shortcut name
|
||||
self.shortcut_name = self.shortcut_name_edit.text().strip() or "Wabbajack"
|
||||
|
||||
# Confirm with user (standard dialog - no safety countdown needed for this operation)
|
||||
confirm = MessageService.question(
|
||||
self,
|
||||
"Confirm Installation",
|
||||
f"Install Wabbajack to:\n{self.install_folder}\n\n"
|
||||
"This will download Wabbajack, add to Steam, install WebView2,\n"
|
||||
"and configure the Wine prefix automatically.\n\n"
|
||||
"Steam will be restarted during installation.\n\n"
|
||||
"Warning: Steam will be restarted during installation - this will close any running game.\n\n"
|
||||
"Continue?",
|
||||
safety_level="medium",
|
||||
)
|
||||
@@ -620,15 +620,6 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
|
||||
def cleanup_processes(self):
|
||||
self._stop_focus_reclaim()
|
||||
if self.worker is not None:
|
||||
try:
|
||||
if self.worker.isRunning():
|
||||
self.worker.requestInterruption()
|
||||
self.worker.wait(5000)
|
||||
self.worker.deleteLater()
|
||||
except Exception:
|
||||
pass
|
||||
self.worker = None
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Called when widget becomes visible"""
|
||||
|
||||
Reference in New Issue
Block a user