Release v0.7.2 - TTW Linux Installer 0.2.0

This commit is contained in:
Omni
2026-07-18 13:46:48 +01:00
parent d78e16f758
commit ada21f90c8
42 changed files with 1111 additions and 605 deletions
+4 -95
View File
@@ -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)
-1
View File
@@ -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)
+18 -6
View File
@@ -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()
+82 -9
View File
@@ -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)