Files
Jackify/jackify/frontends/gui/screens/tools_hub.py
T

585 lines
23 KiB
Python

"""
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("<b>Tools Hub</b>")
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: <b>{current_version}</b>")
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)