""" Tools Hub screen. Manages independently-versioned engines and tools. On each show, the tool list is rebuilt from the effective definitions (remote manifest if fetched, else baked-in). A background thread fetches the manifest; if the tool list changes 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, 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 from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE from jackify.frontends.gui.utils import set_responsive_minimum logger = logging.getLogger(__name__) _C_UPDATE = "#4a5568" _C_BACK = "#4a5568" _C_INSTALL = "#1a5fa8" # -- background threads ------------------------------------------------------ class _VersionCheckThread(QThread): version_ready = Signal(str, str) # tool_id, latest_tag def run(self): registry = ToolRegistry() for defn in get_effective_definitions(): try: tag = registry.check_latest_version(defn.tool_id) self.version_ready.emit(defn.tool_id, tag or "unknown") except Exception as e: logger.debug("Version check failed for %s: %s", defn.tool_id, e) self.version_ready.emit(defn.tool_id, "unknown") class _ToolActionThread(QThread): finished_signal = Signal(str, bool, str) # tool_id, success, message def __init__(self, tool_id: str, action: str, version: Optional[str] = None): super().__init__() self._tool_id = tool_id self._action = action self._version = version def run(self): registry = ToolRegistry() try: if self._action == "install": ok, msg = registry.install(self._tool_id, version=self._version) elif self._action == "update": ok, msg = registry.update(self._tool_id) elif self._action == "uninstall": ok, msg = registry.uninstall(self._tool_id) else: ok, msg = False, f"Unknown action: {self._action}" except Exception as e: ok, msg = False, str(e) 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] def run(self): result = fetch_remote_manifest() if result: self.manifest_ready.emit(result) class _ReleaseFetchThread(QThread): releases_ready = Signal(str, list) # tool_id, List[dict] def __init__(self, tool_id: str, github_repo: str): super().__init__() self._tool_id = tool_id self._github_repo = github_repo def run(self): releases = fetch_release_list(self._github_repo) 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.""" def __init__(self, stacked_widget=None, main_menu_index: int = 0, ttw_screen_index: int = 5, parent=None): super().__init__(parent) self.stacked_widget = stacked_widget self.main_menu_index = main_menu_index self.ttw_screen_index = ttw_screen_index self._cards: Dict[str, ToolCard] = {} self._action_thread: Optional[_ToolActionThread] = None 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() def _setup_ui(self): root = QVBoxLayout() root.setContentsMargins(30, 24, 30, 24) root.setSpacing(0) 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(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) header_row.addWidget(jackify_version_label) header_row.addSpacing(8) header_row.addWidget(self._btn_update_jackify) header_row.addStretch() title = QLabel("Tools Hub") title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};") header_row.addWidget(title) header_row.addStretch() header_row.addWidget(self._btn_update_all) root.addLayout(header_row) root.addSpacing(6) disclaimer = QLabel( "Some of these tools are developed and maintained by their respective authors, " "independently of Jackify. Jackify provides download and update management " "as a convenience only. The Jackify project offers no warranty or support " "for third-party tools." ) disclaimer.setWordWrap(True) disclaimer.setStyleSheet("color: #aaa; font-size: 12px;") root.addWidget(disclaimer) root.addSpacing(10) sep = QLabel() sep.setFixedHeight(2) sep.setStyleSheet("background: #fff;") root.addWidget(sep) root.addSpacing(12) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.NoFrame) scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }") scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._list_widget = QWidget() self._list_widget.setStyleSheet("background: transparent;") self._list_layout = QVBoxLayout() self._list_layout.setContentsMargins(0, 0, 0, 0) self._list_layout.setSpacing(6) self._list_widget.setLayout(self._list_layout) scroll.setWidget(self._list_widget) root.addWidget(scroll, stretch=1) root.addSpacing(12) back_row = QHBoxLayout() back_row.addStretch() back_btn = QPushButton("Back to Main Menu") back_btn.setFixedSize(160, 34) back_btn.setStyleSheet( f"QPushButton {{ background-color: {_C_BACK}; color: white; border: none; " f"border-radius: 6px; font-size: 12px; font-weight: bold; }}" f"QPushButton:hover {{ background-color: #5a6578; }}" f"QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }}" ) back_btn.clicked.connect(self._go_back) back_row.addWidget(back_btn) back_row.addStretch() root.addLayout(back_row) # card list management def _rebuild_card_list(self): while self._list_layout.count(): item = self._list_layout.takeAt(0) if item.widget(): item.widget().deleteLater() self._cards.clear() statuses = ToolRegistry().get_all_statuses() engines = [s for s in statuses if s.definition.is_engine] tools = [s for s in statuses if not s.definition.is_engine] if engines: self._list_layout.addWidget(section_header("Engine")) self._list_layout.addSpacing(4) for s in engines: self._add_card(s) self._list_layout.addSpacing(10) if tools: self._list_layout.addWidget(section_header("Tools")) self._list_layout.addSpacing(4) for s in tools: self._add_card(s) placeholder = QLabel("More tools coming soon") placeholder.setAlignment(Qt.AlignCenter) placeholder.setStyleSheet( "color: #555; font-size: 12px; font-style: italic; " "background-color: #222; border: 1px dashed #333; " "border-radius: 6px; padding: 10px;" ) self._list_layout.addWidget(placeholder) self._list_layout.addStretch() def _add_card(self, status: ToolStatus): card = ToolCard(status, self._active_engine_id) card.action_requested.connect(self._on_action) card.engine_activated.connect(self._on_engine_activated) self._cards[status.definition.tool_id] = card self._list_layout.addWidget(card) # show / manifest / version check def showEvent(self, event): super().showEvent(event) try: mw = self.window() if mw: set_responsive_minimum(mw, min_width=960, min_height=520) except Exception: pass self._active_engine_id = get_active_engine_id() 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(): return self._manifest_thread = _ManifestFetchThread() self._manifest_thread.manifest_ready.connect(self._on_manifest_ready) self._manifest_thread.start() def _on_manifest_ready(self, definitions: List[ToolDefinition]): current_ids = set(self._cards.keys()) 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(): self._version_thread.quit() self._rebuild_card_list() self._start_version_check() def _start_version_check(self): if self._version_thread and self._version_thread.isRunning(): return self._version_thread = _VersionCheckThread() self._version_thread.version_ready.connect(self._on_version_ready) self._version_thread.start() def _on_version_ready(self, tool_id: str, tag: str): card = self._cards.get(tool_id) if card: 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, width=134)) any_updates = any(c._status.update_available for c in self._cards.values()) main_menu = self._get_main_menu() if main_menu: main_menu.notify_tool_updates(any_updates) def _get_main_menu(self): try: from jackify.frontends.gui.screens.main_menu import MainMenu w = self.window() if w and hasattr(w, 'main_menu'): return w.main_menu except Exception: pass return None # engine activation def _on_engine_activated(self, tool_id: str): self._active_engine_id = tool_id for card in self._cards.values(): card.set_active_engine(tool_id) card = self._cards.get(tool_id) name = card._status.definition.display_name if card else tool_id MessageService.information(self, "Engine Changed", f"{name} is now the active engine.") # action dispatch def _on_action(self, tool_id: str, action: str): if action == "launch_jackify_ui": if self.stacked_widget: self.stacked_widget.setCurrentIndex(self.ttw_screen_index) ttw_screen = self.stacked_widget.widget(self.ttw_screen_index) if hasattr(ttw_screen, 'main_menu_index'): ttw_screen.main_menu_index = self.stacked_widget.indexOf(self) return if self._action_thread and self._action_thread.isRunning(): MessageService.information(self, "Busy", "Another operation is running. Please wait.") return if action == "downgrade": self._start_downgrade_flow(tool_id) return card = self._cards.get(tool_id) if card: label_map = { "install": "Installing...", "update": "Updating...", "uninstall": "Removing...", } card.set_busy(True, label_map.get(action, "Working...")) self._action_thread = _ToolActionThread(tool_id, action) self._action_thread.finished_signal.connect(self._on_action_finished) self._action_thread.start() 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: card.mark_installed(status.installed_version or "") if status.latest_version: card.set_latest_version(status.latest_version) elif card: card.mark_uninstalled() if message: MessageService.information(self, "Done", message) else: if card: 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] if not updates: return names = ", ".join(self._cards[tid]._status.definition.display_name for tid in updates) if MessageService.question(self, "Update All", f"Update the following tools?\n\n{names}") != QMessageBox.Yes: return self._pending_updates: List[str] = updates self._run_next_update() def _run_next_update(self): if not self._pending_updates: return tool_id = self._pending_updates.pop(0) card = self._cards.get(tool_id) if card: card.set_busy(True, "Updating...") self._action_thread = _ToolActionThread(tool_id, "update") self._action_thread.finished_signal.connect(self._on_update_all_step) self._action_thread.start() def _on_update_all_step(self, tool_id: str, success: bool, message: str): self._action_thread = None self._on_action_finished(tool_id, success, message if not success else "") if getattr(self, "_pending_updates", []): self._run_next_update() else: 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, width=134)) def _start_downgrade_flow(self, tool_id: str): card = self._cards.get(tool_id) 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) self._release_thread.releases_ready.connect(self._on_releases_ready) self._release_thread.start() def _on_releases_ready(self, tool_id: str, releases: list): card = self._cards.get(tool_id) if card: card.set_busy(False) if not releases: MessageService.warning(self, "Change Version", "Could not fetch release list from GitHub.") return status = ToolRegistry().get_status(tool_id) current = status.installed_version if status else None version = self._show_version_picker(tool_id, releases, current) if not version: return if card: card.set_busy(True, "Changing version...") self._action_thread = _ToolActionThread(tool_id, "install", version=version) self._action_thread.finished_signal.connect(self._on_action_finished) self._action_thread.start() def _show_version_picker(self, tool_id: str, releases: list, current_version: Optional[str]) -> Optional[str]: dlg = QDialog(self.window()) dlg.setWindowTitle("Select Version") dlg.setWindowModality(Qt.ApplicationModal) dlg.setMinimumWidth(360) dlg.setStyleSheet( "QDialog { background-color: #232323; color: #e0e0e0; }" "QLabel { color: #e0e0e0; background: transparent; border: none; }" "QComboBox { background-color: #2a2a2a; color: #e0e0e0; border: 1px solid #444; " " border-radius: 4px; padding: 4px 8px; }" "QComboBox QAbstractItemView { background-color: #2a2a2a; color: #e0e0e0; " " selection-background-color: #3a3a3a; }" ) layout = QVBoxLayout(dlg) layout.setContentsMargins(20, 18, 20, 18) layout.setSpacing(12) if current_version: lbl = QLabel(f"Currently installed: {current_version}") lbl.setTextFormat(Qt.RichText) layout.addWidget(lbl) combo = QComboBox() for rel in releases: tag = rel.get("tag_name") or rel.get("name", "") date = (rel.get("published_at") or "")[:10] combo.addItem(f"{tag} ({date})", userData=tag) layout.addWidget(combo) sep = QLabel() sep.setFixedHeight(1) sep.setStyleSheet("background: #3a3a3a; border: none;") layout.addWidget(sep) btn_row = QHBoxLayout() btn_row.addStretch() cancel_btn = QPushButton("Cancel") cancel_btn.setFixedSize(90, 30) cancel_btn.setStyleSheet(btn_style(_C_BACK)) cancel_btn.clicked.connect(dlg.reject) btn_row.addWidget(cancel_btn) install_btn = QPushButton("Install") install_btn.setFixedSize(90, 30) install_btn.setStyleSheet(btn_style(_C_INSTALL)) install_btn.setDefault(True) install_btn.clicked.connect(dlg.accept) btn_row.addWidget(install_btn) layout.addLayout(btn_row) if dlg.exec() != QDialog.Accepted: return None return combo.currentData() def _go_back(self): if self.stacked_widget: self.stacked_widget.setCurrentIndex(self.main_menu_index)