Files
Jackify/jackify/frontends/gui/screens/third_party_tools.py
2026-04-20 20:57:23 +01:00

479 lines
18 KiB
Python

"""
Third Party Tools screen.
Lists independently-managed tools with install status, version info,
and Install / Update / Downgrade / Uninstall actions per tool.
Version checks run in a background thread so the screen loads instantly.
"""
import logging
from typing import Dict, Optional
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtWidgets import (
QFrame, QHBoxLayout, QLabel, QPushButton,
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
)
from jackify.backend.services.tool_registry import TOOL_DEFINITIONS, ToolRegistry, ToolStatus
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
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__)
# ---------------------------------------------------------------------------
# Colours
# ---------------------------------------------------------------------------
_BTN_INSTALL = "#1a5fa8"
_BTN_UPDATE = "#2a6e2a"
_BTN_DOWNGRADE = "#7a5a00"
_BTN_UNINSTALL = "#6b2020"
_BTN_DISABLED = "#333"
_BADGE_NOT_INSTALLED = ("#555", "#ccc") # bg, fg
_BADGE_UP_TO_DATE = ("#1e4d1e", "#8fdc8f")
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
_BADGE_CHECKING = ("#333", "#888")
def _btn_style(colour: str, disabled: bool = False) -> str:
bg = _BTN_DISABLED if disabled else colour
return f"""
QPushButton {{
background-color: {bg};
color: {'#666' if disabled else 'white'};
border: none; border-radius: 4px;
font-size: 11px; font-weight: bold;
padding: 4px 8px;
}}
QPushButton:hover {{ background-color: {'#444' if disabled else bg}; }}
QPushButton:pressed {{ background-color: {bg}; }}
"""
# ---------------------------------------------------------------------------
# Background version-check thread
# ---------------------------------------------------------------------------
class _VersionCheckThread(QThread):
version_ready = Signal(str, str) # tool_id, latest_version_tag
def run(self):
registry = ToolRegistry()
for defn in TOOL_DEFINITIONS:
try:
tag = registry.check_latest_version(defn.tool_id)
if tag:
self.version_ready.emit(defn.tool_id, tag)
except Exception as e:
logger.debug("Version check failed for %s: %s", defn.tool_id, e)
# ---------------------------------------------------------------------------
# Background install/update/downgrade/uninstall thread
# ---------------------------------------------------------------------------
class _ToolActionThread(QThread):
finished_signal = Signal(str, bool, str) # tool_id, success, message
def __init__(self, tool_id: str, action: str):
super().__init__()
self._tool_id = tool_id
self._action = action
def run(self):
registry = ToolRegistry()
try:
if self._action == "install":
ok, msg = registry.install(self._tool_id)
elif self._action == "update":
ok, msg = registry.update(self._tool_id)
elif self._action == "downgrade":
ok, msg = registry.downgrade(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)
# ---------------------------------------------------------------------------
# Per-tool card widget
# ---------------------------------------------------------------------------
class _ToolCard(QFrame):
action_requested = Signal(str, str) # tool_id, action
def __init__(self, status: ToolStatus, parent=None):
super().__init__(parent)
self._tool_id = status.definition.tool_id
self._status = status
self._busy = False
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet("""
QFrame {
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
}
""")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
outer = QHBoxLayout()
outer.setContentsMargins(14, 10, 14, 10)
outer.setSpacing(12)
# --- Left: name + description ---
info_col = QVBoxLayout()
info_col.setSpacing(2)
tier_tag = " [required]" if status.definition.tier == 1 else ""
name_label = QLabel(f"<b>{status.definition.display_name}</b>{tier_tag}")
name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
info_col.addWidget(name_label)
desc_label = QLabel(status.definition.description)
desc_label.setWordWrap(True)
desc_label.setStyleSheet("color: #888; font-size: 11px; background: transparent; border: none;")
info_col.addWidget(desc_label)
info_widget = QWidget()
info_widget.setLayout(info_col)
info_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
info_widget.setStyleSheet("background: transparent; border: none;")
outer.addWidget(info_widget, stretch=3)
# --- Centre: status badge + version ---
centre_col = QVBoxLayout()
centre_col.setSpacing(4)
centre_col.setAlignment(Qt.AlignCenter)
self._badge = QLabel()
self._badge.setAlignment(Qt.AlignCenter)
self._badge.setFixedWidth(130)
self._badge.setStyleSheet("border-radius: 3px; padding: 2px 6px; font-size: 11px; font-weight: bold;")
centre_col.addWidget(self._badge, alignment=Qt.AlignCenter)
self._version_label = QLabel()
self._version_label.setAlignment(Qt.AlignCenter)
self._version_label.setStyleSheet("color: #777; font-size: 10px; background: transparent; border: none;")
centre_col.addWidget(self._version_label, alignment=Qt.AlignCenter)
centre_widget = QWidget()
centre_widget.setLayout(centre_col)
centre_widget.setFixedWidth(150)
centre_widget.setStyleSheet("background: transparent; border: none;")
outer.addWidget(centre_widget)
# --- Right: action buttons ---
btn_col = QVBoxLayout()
btn_col.setSpacing(4)
btn_col.setAlignment(Qt.AlignCenter)
self._btn_primary = QPushButton()
self._btn_primary.setFixedWidth(90)
self._btn_primary.clicked.connect(self._on_primary)
btn_col.addWidget(self._btn_primary)
self._btn_downgrade = QPushButton("Downgrade")
self._btn_downgrade.setFixedWidth(90)
self._btn_downgrade.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "downgrade"))
btn_col.addWidget(self._btn_downgrade)
self._btn_uninstall = QPushButton("Uninstall")
self._btn_uninstall.setFixedWidth(90)
self._btn_uninstall.clicked.connect(self._on_uninstall)
btn_col.addWidget(self._btn_uninstall)
btn_widget = QWidget()
btn_widget.setLayout(btn_col)
btn_widget.setFixedWidth(110)
btn_widget.setStyleSheet("background: transparent; border: none;")
outer.addWidget(btn_widget)
self.setLayout(outer)
self._refresh_ui(status)
# ------------------------------------------------------------------
def _refresh_ui(self, status: ToolStatus):
self._status = status
installed = status.installed
update_avail = status.update_available
can_downgrade = status.can_downgrade
can_uninstall = status.definition.can_uninstall
# Badge
if not installed:
bg, fg = _BADGE_NOT_INSTALLED
badge_text = "Not Installed"
elif update_avail:
bg, fg = _BADGE_UPDATE_AVAIL
badge_text = "Update Available"
else:
bg, fg = _BADGE_UP_TO_DATE
badge_text = "Installed"
self._badge.setText(badge_text)
self._badge.setStyleSheet(
f"background-color: {bg}; color: {fg}; border-radius: 3px; "
f"padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;"
)
# Version line
installed_ver = status.installed_version or "-"
latest_ver = status.latest_version or "checking..."
if installed:
self._version_label.setText(f"Installed: {installed_ver}\nLatest: {latest_ver}")
else:
self._version_label.setText(f"Latest: {latest_ver}")
# Primary button
if not installed:
self._btn_primary.setText("Install")
self._btn_primary.setStyleSheet(_btn_style(_BTN_INSTALL))
self._btn_primary.setEnabled(True)
elif update_avail:
self._btn_primary.setText("Update")
self._btn_primary.setStyleSheet(_btn_style(_BTN_UPDATE))
self._btn_primary.setEnabled(True)
else:
self._btn_primary.setText("Reinstall")
self._btn_primary.setStyleSheet(_btn_style(_BTN_INSTALL))
self._btn_primary.setEnabled(True)
# Downgrade button
self._btn_downgrade.setStyleSheet(_btn_style(_BTN_DOWNGRADE, disabled=not can_downgrade))
self._btn_downgrade.setEnabled(can_downgrade and not self._busy)
# Uninstall button
self._btn_uninstall.setVisible(can_uninstall)
if can_uninstall:
self._btn_uninstall.setStyleSheet(_btn_style(_BTN_UNINSTALL, disabled=not installed))
self._btn_uninstall.setEnabled(installed and not self._busy)
if self._busy:
self._btn_primary.setEnabled(False)
self._btn_primary.setStyleSheet(_btn_style(_BTN_DISABLED, disabled=True))
def set_latest_version(self, tag: str):
self._status.latest_version = tag
if self._status.installed and self._status.installed_version:
installed = self._status.installed_version.lstrip("v")
latest = tag.lstrip("v")
self._status.update_available = latest != installed
self._refresh_ui(self._status)
def set_busy(self, busy: bool, label: Optional[str] = None):
self._busy = busy
if busy and label:
self._btn_primary.setText(label)
self._refresh_ui(self._status)
def mark_installed(self, version: str):
self._status.installed = True
self._status.installed_version = version
self._status.update_available = False
self._busy = False
self._refresh_ui(self._status)
def mark_uninstalled(self):
self._status.installed = False
self._status.installed_version = None
self._status.update_available = False
self._busy = False
self._refresh_ui(self._status)
# ------------------------------------------------------------------
def _on_primary(self):
if not self._status.installed:
self.action_requested.emit(self._tool_id, "install")
elif self._status.update_available:
self.action_requested.emit(self._tool_id, "update")
else:
self.action_requested.emit(self._tool_id, "install")
def _on_uninstall(self):
confirmed = MessageService.question(
self,
"Uninstall Tool",
f"Uninstall {self._status.definition.display_name}?\n\nThis will delete the installed files.",
)
if confirmed:
self.action_requested.emit(self._tool_id, "uninstall")
# ---------------------------------------------------------------------------
# Main screen
# ---------------------------------------------------------------------------
class ThirdPartyToolsScreen(ThreadLifecycleMixin, QWidget):
"""Third Party Tools management screen."""
def __init__(self, stacked_widget=None, main_menu_index: int = 0, parent=None):
super().__init__(parent)
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self._cards: Dict[str, _ToolCard] = {}
self._action_thread: Optional[_ToolActionThread] = None
self._version_thread: Optional[_VersionCheckThread] = None
self._setup_ui()
def _setup_ui(self):
root = QVBoxLayout()
root.setContentsMargins(30, 24, 30, 24)
root.setSpacing(0)
self.setLayout(root)
# Header
title = QLabel("<b>Third Party Tools</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
root.addWidget(title)
root.addSpacing(6)
desc = QLabel(
"Install and manage independently-updated tools used by Jackify workflows or run via MO2.\n"
"Tools marked [required] are needed by existing Jackify workflows."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #aaa; font-size: 12px;")
desc.setAlignment(Qt.AlignHCenter)
root.addWidget(desc)
root.addSpacing(10)
sep = QLabel()
sep.setFixedHeight(1)
sep.setStyleSheet("background: #444;")
root.addWidget(sep)
root.addSpacing(12)
# Scrollable tool list
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }")
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
list_widget = QWidget()
list_widget.setStyleSheet("background: transparent;")
self._list_layout = QVBoxLayout()
self._list_layout.setContentsMargins(0, 0, 0, 0)
self._list_layout.setSpacing(8)
list_widget.setLayout(self._list_layout)
registry = ToolRegistry()
for status in registry.get_all_statuses():
card = _ToolCard(status)
card.action_requested.connect(self._on_action)
self._cards[status.definition.tool_id] = card
self._list_layout.addWidget(card)
self._list_layout.addStretch()
scroll.setWidget(list_widget)
root.addWidget(scroll, stretch=1)
root.addSpacing(12)
# Back button
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: #4a5568; color: white;
border: none; border-radius: 5px;
font-size: 12px; font-weight: bold;
}}
QPushButton:hover {{ background-color: #5a6578; }}
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)
# ------------------------------------------------------------------
# Version check on show
# ------------------------------------------------------------------
def showEvent(self, event):
super().showEvent(event)
try:
main_window = self.window()
if main_window:
set_responsive_minimum(main_window, min_width=960, min_height=520)
except Exception:
pass
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:
card.set_latest_version(tag)
# ------------------------------------------------------------------
# Action dispatch
# ------------------------------------------------------------------
def _on_action(self, tool_id: str, action: str):
if self._action_thread and self._action_thread.isRunning():
MessageService.information(self, "Busy", "Another operation is already running. Please wait.")
return
card = self._cards.get(tool_id)
if card:
label_map = {"install": "Installing...", "update": "Updating...",
"downgrade": "Downgrading...", "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 success:
registry = ToolRegistry()
status = registry.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()
MessageService.information(self, "Done", message)
else:
if card:
card.set_busy(False)
MessageService.warning(self, "Failed", message)
# ------------------------------------------------------------------
def _go_back(self):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def cleanup_processes(self):
self._park_all_threads()