mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 04:13:45 +02:00
Release v0.7.2 - TTW Linux Installer 0.2.0
This commit is contained in:
@@ -16,10 +16,9 @@ from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QGroupBox, QTextEdit, QApplication
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QFont, QClipboard
|
||||
|
||||
from ....backend.services.update_service import UpdateService
|
||||
from ....backend.models.configuration import SystemInfo
|
||||
from .... import __version__
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
@@ -27,34 +26,13 @@ from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleM
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateCheckThread(QThread):
|
||||
"""Background thread for checking updates."""
|
||||
|
||||
update_check_finished = Signal(object) # UpdateInfo or None
|
||||
|
||||
def __init__(self, update_service: UpdateService):
|
||||
super().__init__()
|
||||
self.update_service = update_service
|
||||
|
||||
def run(self):
|
||||
"""Check for updates in background."""
|
||||
try:
|
||||
update_info = self.update_service.check_for_updates()
|
||||
self.update_check_finished.emit(update_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking for updates: {e}")
|
||||
self.update_check_finished.emit(None)
|
||||
|
||||
|
||||
class AboutDialog(ThreadLifecycleMixin, QDialog):
|
||||
"""About dialog showing system info and app details."""
|
||||
|
||||
|
||||
def __init__(self, system_info: SystemInfo, parent=None):
|
||||
super().__init__(parent)
|
||||
self.system_info = system_info
|
||||
self.update_service = UpdateService(__version__)
|
||||
self.update_check_thread = None
|
||||
|
||||
|
||||
self.setup_ui()
|
||||
self.setup_connections()
|
||||
|
||||
@@ -117,45 +95,9 @@ class AboutDialog(ThreadLifecycleMixin, QDialog):
|
||||
|
||||
layout.addWidget(jackify_group)
|
||||
|
||||
# Update status
|
||||
self.update_status_label = QLabel("")
|
||||
self.update_status_label.setStyleSheet("color: #666; font-size: 10pt; margin: 5px;")
|
||||
self.update_status_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.update_status_label)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
# Update check button
|
||||
self.update_button = QPushButton("Check for Updates")
|
||||
self.update_button.clicked.connect(self.check_for_updates)
|
||||
self.update_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #23272e;
|
||||
color: #3fd0ea;
|
||||
font-weight: bold;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #3fd0ea;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #3fd0ea;
|
||||
color: #23272e;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #2bb8d6;
|
||||
color: #23272e;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #444;
|
||||
color: #666;
|
||||
border-color: #666;
|
||||
}
|
||||
""")
|
||||
button_layout.addWidget(self.update_button)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
|
||||
# Copy Info button
|
||||
copy_button = QPushButton("Copy Info")
|
||||
copy_button.clicked.connect(self.copy_system_info)
|
||||
@@ -324,36 +266,6 @@ class AboutDialog(ThreadLifecycleMixin, QDialog):
|
||||
logger.error(f"Error getting engine version: {e}")
|
||||
return "Unknown"
|
||||
|
||||
def check_for_updates(self):
|
||||
"""Check for updates in background."""
|
||||
if self.update_check_thread and self.update_check_thread.isRunning():
|
||||
return
|
||||
|
||||
self.update_button.setEnabled(False)
|
||||
self.update_button.setText("Checking...")
|
||||
self.update_status_label.setText("Checking for updates...")
|
||||
|
||||
self.update_check_thread = UpdateCheckThread(self.update_service)
|
||||
self.update_check_thread.update_check_finished.connect(self.update_check_finished)
|
||||
self.update_check_thread.start()
|
||||
|
||||
def update_check_finished(self, update_info):
|
||||
"""Handle update check completion."""
|
||||
self.update_button.setEnabled(True)
|
||||
self.update_button.setText("Check for Updates")
|
||||
|
||||
if update_info:
|
||||
self.update_status_label.setText(f"Update available: v{update_info.version}")
|
||||
self.update_status_label.setStyleSheet("color: #3fd0ea; font-size: 10pt; margin: 5px;")
|
||||
|
||||
# Show update dialog
|
||||
from .update_dialog import UpdateDialog
|
||||
update_dialog = UpdateDialog(update_info, self.update_service, self)
|
||||
update_dialog.exec()
|
||||
else:
|
||||
self.update_status_label.setText("You're running the latest version")
|
||||
self.update_status_label.setStyleSheet("color: #666; font-size: 10pt; margin: 5px;")
|
||||
|
||||
def copy_system_info(self):
|
||||
"""Copy system information to clipboard."""
|
||||
try:
|
||||
@@ -421,7 +333,4 @@ Python: {platform.python_version()}"""
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle dialog close event."""
|
||||
self.update_check_thread = self._park_thread(
|
||||
self.update_check_thread, ["update_available", "no_update", "check_failed"]
|
||||
)
|
||||
event.accept()
|
||||
@@ -319,14 +319,16 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
# Save component installation method preference
|
||||
if self.winetricks_radio.isChecked():
|
||||
method = 'winetricks'
|
||||
else: # protontricks_radio (alternative)
|
||||
elif self.protontricks_radio.isChecked():
|
||||
method = 'system_protontricks'
|
||||
else: # native_radio (default)
|
||||
method = 'native'
|
||||
|
||||
old_method = self.config_handler.get('component_installation_method', 'winetricks')
|
||||
old_method = self.config_handler.get('component_installation_method', 'native')
|
||||
method_changed = (old_method != method)
|
||||
|
||||
self.config_handler.set("component_installation_method", method)
|
||||
self.config_handler.set("use_winetricks_for_components", method == 'winetricks')
|
||||
self.config_handler.set("use_winetricks_for_components", method != 'system_protontricks')
|
||||
|
||||
# Force immediate save and verify
|
||||
save_result = self.config_handler.save_config()
|
||||
|
||||
@@ -262,18 +262,29 @@ class SettingsDialogTabsMixin:
|
||||
component_layout.addWidget(QLabel("Wine Components Installation:"))
|
||||
self.component_method_group = QButtonGroup()
|
||||
component_method_layout = QVBoxLayout()
|
||||
current_method = self.config_handler.get('component_installation_method', 'winetricks')
|
||||
current_method = self.config_handler.get('component_installation_method', 'native')
|
||||
if current_method == 'bundled_protontricks':
|
||||
current_method = 'system_protontricks'
|
||||
self.winetricks_radio = QRadioButton("Winetricks (Default)")
|
||||
self.native_radio = QRadioButton("Native (Default)")
|
||||
self.native_radio.setChecked(current_method == 'native')
|
||||
self.native_radio.setToolTip(
|
||||
"Install components directly, without winetricks or protontricks, falling back to "
|
||||
"bundled winetricks only for the handful of components not yet supported natively."
|
||||
)
|
||||
self.component_method_group.addButton(self.native_radio, 0)
|
||||
component_method_layout.addWidget(self.native_radio)
|
||||
self.winetricks_radio = QRadioButton("Winetricks")
|
||||
self.winetricks_radio.setChecked(current_method == 'winetricks')
|
||||
self.winetricks_radio.setToolTip("Use bundled winetricks for component installation. Faster and more reliable.")
|
||||
self.component_method_group.addButton(self.winetricks_radio, 0)
|
||||
self.winetricks_radio.setToolTip("Use bundled winetricks for every component, bypassing the native installer entirely.")
|
||||
self.component_method_group.addButton(self.winetricks_radio, 1)
|
||||
component_method_layout.addWidget(self.winetricks_radio)
|
||||
self.protontricks_radio = QRadioButton("Protontricks (Alternative)")
|
||||
self.protontricks_radio = QRadioButton("Protontricks")
|
||||
self.protontricks_radio.setChecked(current_method == 'system_protontricks')
|
||||
self.protontricks_radio.setToolTip("Use system-installed protontricks (flatpak or native). Fallback option if winetricks fails.")
|
||||
self.component_method_group.addButton(self.protontricks_radio, 1)
|
||||
self.protontricks_radio.setToolTip(
|
||||
"Use system-installed protontricks (flatpak or native) for every component, "
|
||||
"bypassing the native installer entirely."
|
||||
)
|
||||
self.component_method_group.addButton(self.protontricks_radio, 2)
|
||||
component_method_layout.addWidget(self.protontricks_radio)
|
||||
component_layout.addLayout(component_method_layout)
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ class SuccessDialog(QDialog):
|
||||
"QLabel { color: #3fd0ea; font-size: 11px; margin-top: 4px; padding: 4px; background-color: transparent; }"
|
||||
)
|
||||
readme_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
readme_label.setOpenExternalLinks(False)
|
||||
readme_label.linkActivated.connect(open_url)
|
||||
card_layout.addWidget(readme_label)
|
||||
|
||||
@@ -218,6 +219,7 @@ class SuccessDialog(QDialog):
|
||||
"}"
|
||||
)
|
||||
kofi_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
kofi_label.setOpenExternalLinks(False)
|
||||
kofi_label.linkActivated.connect(open_url)
|
||||
card_layout.addWidget(kofi_label)
|
||||
|
||||
|
||||
@@ -397,7 +397,6 @@ def main(initial_nxm_url: str = ""):
|
||||
window._prefetch_manifests_on_startup()
|
||||
|
||||
if initial_nxm_url:
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(500, lambda: window._on_nxm_url_received(initial_nxm_url))
|
||||
|
||||
# Ensure cleanup on exit
|
||||
|
||||
@@ -47,7 +47,7 @@ class MainWindowStartupMixin:
|
||||
|
||||
def _check_protontricks_on_startup(self):
|
||||
try:
|
||||
method = self.config_handler.get('component_installation_method', 'winetricks')
|
||||
method = self.config_handler.get('component_installation_method', 'native')
|
||||
if method != 'system_protontricks':
|
||||
logger.debug(f"Skipping protontricks check (current method: {method}).")
|
||||
return
|
||||
|
||||
@@ -17,14 +17,28 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
|
||||
"""Detect game type by checking ModOrganizer.ini for loader executables."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Enderal and FNV run on the Skyrim/FO3 engine and share its loader
|
||||
# executables (skse64_loader.exe, etc.), so they must be identified before
|
||||
# the generic engine keyword scan below or they always get misdetected as
|
||||
# skyrim/fallout3.
|
||||
try:
|
||||
from jackify.backend.handlers.modlist_handler import ModlistHandler
|
||||
special = ModlistHandler().detect_special_game_type(install_dir)
|
||||
if special == 'fnv':
|
||||
return 'falloutnv'
|
||||
if special:
|
||||
return special
|
||||
except Exception as e:
|
||||
logger.warning(f"Special game type detection failed: {e}")
|
||||
|
||||
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
return 'skyrim' # Fallback to most common
|
||||
|
||||
|
||||
try:
|
||||
content = mo2_ini.read_text(encoding='utf-8', errors='ignore').lower()
|
||||
|
||||
|
||||
if 'skse64_loader.exe' in content or 'skyrim special edition' in content:
|
||||
return 'skyrim'
|
||||
elif 'f4se_loader.exe' in content or 'fallout 4' in content:
|
||||
|
||||
@@ -15,6 +15,7 @@ import logging
|
||||
from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code, nexus_url_from_error_line
|
||||
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename, is_creation_kit_missing_error
|
||||
from jackify.shared.errors import JackifyError, cc_content_missing, creation_kit_missing
|
||||
from jackify.shared.progress_models import InstallationPhase
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -202,6 +203,23 @@ class InstallerThread(QThread):
|
||||
_stderr_fh.close()
|
||||
logger.info("CLF3 stderr log written to /tmp/clf3_stderr.log")
|
||||
|
||||
def _is_download_phase(self) -> bool:
|
||||
"""True while the engine is downloading, per the structured progress parser.
|
||||
|
||||
[FILE_PROGRESS] per-file console text (filename/percent/speed) is only
|
||||
useful during downloads, where per-file speed is the whole point. During
|
||||
hashing/extract/install it fires once per file with no throttling on the
|
||||
engine side, flooding Show Details with a filename flash for every one of
|
||||
tens of thousands of files. The Activity panel's counters are unaffected
|
||||
either way - they come from progress_state, parsed unconditionally above.
|
||||
"""
|
||||
if not self.progress_state_manager:
|
||||
return False
|
||||
try:
|
||||
return self.progress_state_manager.get_state().phase == InstallationPhase.DOWNLOAD
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _remember_stdout_line(self, line: str) -> None:
|
||||
"""Keep a bounded tail of meaningful stdout lines for failure diagnostics."""
|
||||
cleaned = (line or "").strip()
|
||||
@@ -604,6 +622,10 @@ class InstallerThread(QThread):
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.progress_received.emit(parts[0].rstrip())
|
||||
if self._is_download_phase():
|
||||
tail = parts[1].strip() if len(parts) > 1 else ''
|
||||
if tail:
|
||||
self.progress_received.emit(tail + '\r')
|
||||
else:
|
||||
self.progress_received.emit(decoded + '\r')
|
||||
elif b'\n' in buffer:
|
||||
@@ -671,6 +693,10 @@ class InstallerThread(QThread):
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
if self._is_download_phase():
|
||||
tail = parts[1].strip() if len(parts) > 1 else ''
|
||||
if tail:
|
||||
self.output_received.emit(tail + '\r')
|
||||
last_was_blank = False
|
||||
continue
|
||||
if decoded.strip() == '':
|
||||
@@ -687,6 +713,10 @@ class InstallerThread(QThread):
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
if self._is_download_phase():
|
||||
tail = parts[1].strip() if len(parts) > 1 else ''
|
||||
if tail:
|
||||
self.output_received.emit(tail + '\r')
|
||||
else:
|
||||
self._remember_stdout_line(decoded)
|
||||
self.output_received.emit(decoded)
|
||||
|
||||
@@ -404,12 +404,24 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html</i>"""
|
||||
|
||||
def reset_screen_to_defaults(self):
|
||||
"""Reset the screen to default state when navigating back from main menu"""
|
||||
if not getattr(self, '_integration_mode', False):
|
||||
# Reset form fields only when not pre-populated by a caller
|
||||
self.file_edit.setText("")
|
||||
self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir())
|
||||
self.console.clear()
|
||||
self.process_monitor.clear()
|
||||
# Clear integration mode first - a caller (e.g. the Begin Again automated TTW
|
||||
# trigger) sets it again immediately after this runs, via set_modlist_integration_mode.
|
||||
# Without this reset it stays True forever once set, so a later standalone TTW
|
||||
# install would silently try to integrate into a stale modlist from a prior run.
|
||||
self._integration_mode = False
|
||||
self.file_edit.setText("")
|
||||
self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir())
|
||||
self.console.clear()
|
||||
self.process_monitor.clear()
|
||||
self.status_banner.setText("Ready to install")
|
||||
self.status_banner.setStyleSheet(f"""
|
||||
background-color: #2a2a2a;
|
||||
color: {JACKIFY_COLOR_BLUE};
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
""")
|
||||
|
||||
# Re-enable controls (in case they were disabled from previous errors)
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from PySide6.QtCore import QThread, Signal, Qt
|
||||
from PySide6.QtWidgets import QProgressDialog, QApplication
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE
|
||||
from pathlib import Path
|
||||
import traceback
|
||||
import os
|
||||
@@ -34,6 +35,16 @@ class TTWIntegrationMixin:
|
||||
ttw_target = Path(install_dir) / "mods" / "[NoDelete] Tale of Two Wastelands"
|
||||
self.install_dir_edit.setText(str(ttw_target))
|
||||
|
||||
self.status_banner.setText("Please fill in the details above and click Start Install")
|
||||
self.status_banner.setStyleSheet(f"""
|
||||
background-color: #2a2a2a;
|
||||
color: {JACKIFY_COLOR_BLUE};
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
""")
|
||||
|
||||
# Reset saved geometry so showEvent can properly collapse from current window size
|
||||
self._saved_geometry = None
|
||||
self._saved_min_size = None
|
||||
|
||||
@@ -13,14 +13,15 @@ class TTWOutputMixin:
|
||||
if not hasattr(self, '_ttw_seen_lines'):
|
||||
self._ttw_seen_lines = set()
|
||||
self._ttw_current_phase = None
|
||||
self._ttw_last_progress = 0
|
||||
self._ttw_last_activity_update = 0
|
||||
self._ttw_bsa_total = 0
|
||||
self._ttw_bsa_done = 0
|
||||
self.ttw_start_time = time.time()
|
||||
|
||||
lines_to_display = []
|
||||
html_fragments = []
|
||||
show_details_due_to_error = False
|
||||
latest_progress = None
|
||||
bsa_progress_changed = False
|
||||
|
||||
for cleaned in messages:
|
||||
if not cleaned:
|
||||
@@ -29,26 +30,30 @@ class TTWOutputMixin:
|
||||
lower_cleaned = cleaned.lower()
|
||||
|
||||
try:
|
||||
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
||||
if progress_match:
|
||||
current = int(progress_match.group(1))
|
||||
total = int(progress_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
latest_progress = (current, total, percent)
|
||||
if not self._ttw_bsa_total:
|
||||
bsa_total_match = re.search(r'(\d+)\s+output BSAs', cleaned)
|
||||
if bsa_total_match:
|
||||
self._ttw_bsa_total = int(bsa_total_match.group(1))
|
||||
bsa_progress_changed = True
|
||||
|
||||
if 'loading manifest:' in lower_cleaned:
|
||||
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
|
||||
if manifest_match:
|
||||
current = int(manifest_match.group(1))
|
||||
total = int(manifest_match.group(2))
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned
|
||||
is_warning = 'warning:' in lower_cleaned
|
||||
is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid'])
|
||||
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
|
||||
is_bsa_progress = cleaned.startswith('[BSA]')
|
||||
is_bsa_done = is_bsa_progress and ('... ok' in lower_cleaned or '... failed' in lower_cleaned)
|
||||
if is_bsa_done:
|
||||
self._ttw_bsa_done += 1
|
||||
bsa_progress_changed = True
|
||||
is_milestone = is_bsa_progress or any(
|
||||
kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid']
|
||||
)
|
||||
is_file_op = not is_bsa_progress and any(
|
||||
ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx']
|
||||
)
|
||||
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
|
||||
|
||||
if is_error and 'cannot get directory path for location type' in lower_cleaned:
|
||||
@@ -66,12 +71,11 @@ class TTWOutputMixin:
|
||||
else:
|
||||
lines_to_display.append(cleaned)
|
||||
|
||||
if latest_progress:
|
||||
current, total, percent = latest_progress
|
||||
if bsa_progress_changed and self._ttw_bsa_total:
|
||||
current_time = time.time()
|
||||
if abs(percent - self._ttw_last_progress) >= 1 or (current_time - self._ttw_last_activity_update) >= 0.5:
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
self._ttw_last_progress = percent
|
||||
if (current_time - self._ttw_last_activity_update) >= 0.3:
|
||||
percent = int(self._ttw_bsa_done / self._ttw_bsa_total * 100)
|
||||
self._update_ttw_phase("Building archives", self._ttw_bsa_done, self._ttw_bsa_total, percent)
|
||||
self._ttw_last_activity_update = current_time
|
||||
|
||||
if html_fragments or lines_to_display:
|
||||
@@ -129,21 +133,13 @@ class TTWOutputMixin:
|
||||
pass
|
||||
|
||||
try:
|
||||
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
||||
progress_match = re.search(r'Progress:\s*(\d+)%', cleaned)
|
||||
if progress_match:
|
||||
current = int(progress_match.group(1))
|
||||
total = int(progress_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
percent = int(progress_match.group(1))
|
||||
self._update_ttw_activity(percent, 100, percent)
|
||||
|
||||
if 'loading manifest:' in lower_cleaned:
|
||||
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
|
||||
if manifest_match:
|
||||
current = int(manifest_match.group(1))
|
||||
total = int(manifest_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -38,26 +38,16 @@ def on_installation_output_simple(self, message):
|
||||
self._safe_append_text(cleaned)
|
||||
|
||||
# Extract progress for Activity window ONLY - minimal regex with error handling
|
||||
# Pattern: [X/Y] or "Loading manifest: X/Y"
|
||||
# Pattern: "Progress: NN%" or "Loading manifest: <path>"
|
||||
try:
|
||||
# Try to extract [X/Y] pattern
|
||||
import re
|
||||
match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
||||
match = re.search(r'Progress:\s*(\d+)%', cleaned)
|
||||
if match:
|
||||
current = int(match.group(1))
|
||||
total = int(match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
phase = self._ttw_current_phase or "Processing"
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
|
||||
# Try "Loading manifest: X/Y"
|
||||
match = re.search(r'loading manifest:\s*(\d+)/(\d+)', cleaned.lower())
|
||||
if match:
|
||||
current = int(match.group(1))
|
||||
total = int(match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
percent = int(match.group(1))
|
||||
self._update_ttw_activity(percent, 100, percent)
|
||||
|
||||
if 'loading manifest:' in cleaned.lower():
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
except (RecursionError, re.error, Exception):
|
||||
# If regex fails, just skip progress extraction - show output anyway
|
||||
pass
|
||||
|
||||
@@ -112,6 +112,7 @@ class TTWUISetupMixin:
|
||||
instruction_text.setWordWrap(True)
|
||||
instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;")
|
||||
instruction_text.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
instruction_text.setOpenExternalLinks(False)
|
||||
instruction_text.linkActivated.connect(open_url)
|
||||
user_config_vbox.addWidget(instruction_text)
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ class TTWWorkflowMixin:
|
||||
self._safe_append_text("Starting TTW installation...")
|
||||
|
||||
self.file_progress_list.clear()
|
||||
self._update_ttw_phase("Initialising TTW installation", 0, 0, 0)
|
||||
self._update_ttw_phase("Initialising TTW installation")
|
||||
QApplication.processEvents()
|
||||
|
||||
self.status_banner.setVisible(True)
|
||||
|
||||
@@ -58,6 +58,8 @@ class ModlistGalleryLoadingMixin:
|
||||
|
||||
# Position overlay in center of content area
|
||||
def position_overlay():
|
||||
if getattr(self, '_loading_overlay', None) is None:
|
||||
return
|
||||
if hasattr(self, 'content_area') and self.content_area.isVisible():
|
||||
content_width = self.content_area.width()
|
||||
content_height = self.content_area.height()
|
||||
|
||||
@@ -11,17 +11,19 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtCore import Qt, QThread, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QDialog, QDialogButtonBox, QFrame, QHBoxLayout, QLabel,
|
||||
QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from jackify import __version__ as _JACKIFY_VERSION
|
||||
from jackify.backend.services.tool_registry import (
|
||||
ToolDefinition, ToolRegistry, ToolStatus,
|
||||
apply_remote_manifest, fetch_remote_manifest, fetch_release_list,
|
||||
get_active_engine_id, get_effective_definitions,
|
||||
)
|
||||
from jackify.backend.services.update_service import UpdateInfo, UpdateService
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
from jackify.frontends.gui.screens.tools_hub_card import ToolCard, btn_style, section_header
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
@@ -113,6 +115,22 @@ class _ReleaseFetchThread(QThread):
|
||||
self.releases_ready.emit(self._tool_id, releases)
|
||||
|
||||
|
||||
class _JackifyUpdateCheckThread(QThread):
|
||||
update_ready = Signal(object) # UpdateInfo or None
|
||||
|
||||
def __init__(self, update_service: UpdateService):
|
||||
super().__init__()
|
||||
self._update_service = update_service
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
update_info = self._update_service.check_for_updates()
|
||||
except Exception as e:
|
||||
logger.debug("Jackify update check failed: %s", e)
|
||||
update_info = None
|
||||
self.update_ready.emit(update_info)
|
||||
|
||||
|
||||
# -- main screen -------------------------------------------------------------
|
||||
class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
"""Tools Hub: engine selection and third-party tool management."""
|
||||
@@ -128,6 +146,10 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
self._version_thread: Optional[_VersionCheckThread] = None
|
||||
self._manifest_thread: Optional[_ManifestFetchThread] = None
|
||||
self._release_thread: Optional[_ReleaseFetchThread] = None
|
||||
self._jackify_update_thread: Optional[_JackifyUpdateCheckThread] = None
|
||||
self._jackify_update_info: Optional[UpdateInfo] = None
|
||||
self._jackify_manual_check_pending = False
|
||||
self._update_service = UpdateService(_JACKIFY_VERSION)
|
||||
self._active_engine_id = get_active_engine_id()
|
||||
|
||||
self._setup_ui()
|
||||
@@ -139,15 +161,25 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
self.setLayout(root)
|
||||
|
||||
header_row = QHBoxLayout()
|
||||
|
||||
jackify_version_label = QLabel(f"Jackify v{_JACKIFY_VERSION}")
|
||||
jackify_version_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
self._btn_update_jackify = QPushButton("Check for Updates")
|
||||
self._btn_update_jackify.setFixedSize(150, 30)
|
||||
self._btn_update_jackify.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self._btn_update_jackify.setStyleSheet(btn_style(_C_UPDATE, width=134))
|
||||
self._btn_update_jackify.clicked.connect(self._on_update_jackify)
|
||||
|
||||
self._btn_update_all = QPushButton("Update All")
|
||||
self._btn_update_all.setFixedSize(100, 30)
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True))
|
||||
self._btn_update_all.setFixedSize(150, 30)
|
||||
self._btn_update_all.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True, width=134))
|
||||
self._btn_update_all.setEnabled(False)
|
||||
self._btn_update_all.clicked.connect(self._on_update_all)
|
||||
# Left spacer matches button width so title stays centred
|
||||
left_spacer = QWidget()
|
||||
left_spacer.setFixedWidth(100)
|
||||
header_row.addWidget(left_spacer)
|
||||
|
||||
header_row.addWidget(jackify_version_label)
|
||||
header_row.addSpacing(8)
|
||||
header_row.addWidget(self._btn_update_jackify)
|
||||
header_row.addStretch()
|
||||
title = QLabel("<b>Tools Hub</b>")
|
||||
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
|
||||
@@ -265,6 +297,47 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
self._rebuild_card_list()
|
||||
self._start_manifest_fetch()
|
||||
self._start_version_check()
|
||||
self._start_jackify_update_check()
|
||||
|
||||
def _start_jackify_update_check(self):
|
||||
if self._jackify_update_thread and self._jackify_update_thread.isRunning():
|
||||
return
|
||||
self._jackify_update_thread = _JackifyUpdateCheckThread(self._update_service)
|
||||
self._jackify_update_thread.update_ready.connect(self._on_jackify_update_ready)
|
||||
self._jackify_update_thread.start()
|
||||
|
||||
def _on_jackify_update_ready(self, update_info: Optional[UpdateInfo]):
|
||||
self._jackify_update_info = update_info
|
||||
manual = self._jackify_manual_check_pending
|
||||
self._jackify_manual_check_pending = False
|
||||
self._btn_update_jackify.setEnabled(True)
|
||||
|
||||
if update_info:
|
||||
self._btn_update_jackify.setText("Update Jackify")
|
||||
if manual:
|
||||
self._open_jackify_update_dialog()
|
||||
else:
|
||||
self._btn_update_jackify.setText("Up to date" if manual else "Check for Updates")
|
||||
if manual:
|
||||
QTimer.singleShot(
|
||||
2500, lambda: self._btn_update_jackify.setText("Check for Updates")
|
||||
)
|
||||
|
||||
def _on_update_jackify(self):
|
||||
if self._jackify_update_info:
|
||||
self._open_jackify_update_dialog()
|
||||
return
|
||||
if self._jackify_update_thread and self._jackify_update_thread.isRunning():
|
||||
return
|
||||
self._jackify_manual_check_pending = True
|
||||
self._btn_update_jackify.setEnabled(False)
|
||||
self._btn_update_jackify.setText("Checking...")
|
||||
self._start_jackify_update_check()
|
||||
|
||||
def _open_jackify_update_dialog(self):
|
||||
from jackify.frontends.gui.dialogs.update_dialog import UpdateDialog
|
||||
dialog = UpdateDialog(self._jackify_update_info, self._update_service, self)
|
||||
dialog.exec()
|
||||
|
||||
def _start_manifest_fetch(self):
|
||||
if self._manifest_thread and self._manifest_thread.isRunning():
|
||||
@@ -296,7 +369,7 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
has_update = card.set_latest_version(tag)
|
||||
if has_update:
|
||||
self._btn_update_all.setEnabled(True)
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE))
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, width=134))
|
||||
any_updates = any(c._status.update_available for c in self._cards.values())
|
||||
main_menu = self._get_main_menu()
|
||||
if main_menu:
|
||||
@@ -423,7 +496,7 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
any_remaining = any(c._status.installed and c._status.update_available for c in self._cards.values())
|
||||
self._btn_update_all.setEnabled(any_remaining)
|
||||
if not any_remaining:
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True))
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True, width=134))
|
||||
|
||||
def _start_downgrade_flow(self, tool_id: str):
|
||||
card = self._cards.get(tool_id)
|
||||
|
||||
@@ -94,6 +94,7 @@ class ToolCard(QFrame):
|
||||
self._name_label = QLabel(name_html)
|
||||
self._name_label.setTextFormat(Qt.RichText)
|
||||
self._name_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
self._name_label.setOpenExternalLinks(False)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user