""" ui/windows/missing_plugins_dialog.py Shown when loading a profile that requires plugins that are either not installed or installed but missing their dependencies. Each row is one of two kinds: 'plugin' — plugin not installed → Download (if source_url) + Install from file… 'deps' — plugin installed but deps absent → Reinstall from file… """ import os import tempfile from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QFileDialog, QMessageBox, QApplication, ) from PyQt6.QtCore import Qt class MissingPluginsDialog(QDialog): def __init__(self, missing_manifests: list, plugin_mgr, parent=None): super().__init__(parent, Qt.WindowType.Dialog) self._plugin_mgr = plugin_mgr self._missing = missing_manifests self._install_btns: dict = {} # plugin_id -> "Install from file…" button self._download_btns: dict = {} # plugin_id -> "Download" button (optional) self._status_lbls: dict = {} # plugin_id -> status QLabel self.setWindowTitle("Missing Plugins") self.setMinimumWidth(540) self._build() # ── Build ───────────────────────────────────────────────────────────── def _build(self): root = QVBoxLayout(self) root.setSpacing(12) root.setContentsMargins(16, 16, 16, 16) intro = QLabel( "This profile requires plugins that are not ready on this machine.\n" "Fix the issues below, then click Continue to finish loading." ) intro.setWordWrap(True) root.addWidget(intro) root.addWidget(_hline()) for pm in self._missing: root.addLayout(self._plugin_row(pm)) root.addWidget(_hline()) btn_row = QHBoxLayout() btn_row.addStretch() cancel_btn = QPushButton("Cancel load") cancel_btn.clicked.connect(self.reject) btn_row.addWidget(cancel_btn) self._continue_btn = QPushButton("Continue") self._continue_btn.setObjectName("applyButton") self._continue_btn.clicked.connect(self.accept) btn_row.addWidget(self._continue_btn) root.addLayout(btn_row) self._refresh_continue_btn() def _plugin_row(self, pm: dict) -> QHBoxLayout: row = QHBoxLayout(); row.setSpacing(8) # Left: name + detail info_col = QVBoxLayout(); info_col.setSpacing(2) name_lbl = QLabel(f"{pm.get('name', pm['plugin_id'])}" f" v{pm.get('version', '?')}") info_col.addWidget(name_lbl) if pm.get("kind") == "deps": detail = "Missing packages: " + ", ".join(pm.get("missing_deps", [])) elif pm.get("description"): detail = pm["description"] else: detail = "" if detail: dl = QLabel(detail) dl.setObjectName("traceSource"); dl.setWordWrap(True) info_col.addWidget(dl) row.addLayout(info_col, 1) # Status label is_deps = pm.get("kind") == "deps" status_lbl = QLabel("Deps missing" if is_deps else "Not installed") status_lbl.setObjectName("traceSource") status_lbl.setFixedWidth(130) status_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self._status_lbls[pm["plugin_id"]] = status_lbl row.addWidget(status_lbl) # Download button — only for missing plugins with a source_url source_url = pm.get("source_url", "") if source_url and not is_deps: dl_btn = QPushButton("Download") dl_btn.setObjectName("configButton") dl_btn.setFixedWidth(90) dl_btn.setToolTip(f"Download from:\n{source_url}") dl_btn.clicked.connect(lambda _, p=pm: self._download(p)) self._download_btns[pm["plugin_id"]] = dl_btn row.addWidget(dl_btn) # Install / Reinstall from file button btn_label = "Reinstall from file…" if is_deps else "Install from file…" inst_btn = QPushButton(btn_label) inst_btn.setObjectName("configButton") inst_btn.setFixedWidth(150) if is_deps: inst_btn.setToolTip("Reinstall with a zip that bundles deps in vendor/") inst_btn.clicked.connect(lambda _, p=pm: self._pick_file(p)) self._install_btns[pm["plugin_id"]] = inst_btn row.addWidget(inst_btn) return row # ── Install paths ───────────────────────────────────────────────────── def _pick_file(self, pm: dict): path, _ = QFileDialog.getOpenFileName( self, f"Install {pm.get('name', pm['plugin_id'])}", "", "Plugin Archives (*.zip)" ) if path: self._finish_install(pm, path, cleanup=False) def _download(self, pm: dict): import urllib.request url = pm.get("source_url", "") if not url: return pid = pm["plugin_id"] self._set_row_busy(pid, True, "Downloading…") tmp_path = None try: tmp_fd, tmp_path = tempfile.mkstemp(suffix=".zip") os.close(tmp_fd) status_lbl = self._status_lbls[pid] def _progress(block_count, block_size, total): if total > 0: pct = min(100, block_count * block_size * 100 // total) status_lbl.setText(f"Downloading… {pct}%") QApplication.processEvents() urllib.request.urlretrieve(url, tmp_path, reporthook=_progress) except Exception as exc: self._set_row_busy(pid, False, "Download failed") QMessageBox.critical(self, "Download Failed", str(exc)) if tmp_path: _silent_remove(tmp_path) return self._status_lbls[pid].setText("Installing…") QApplication.processEvents() self._finish_install(pm, tmp_path, cleanup=True) def _finish_install(self, pm: dict, zip_path: str, cleanup: bool): pid = pm["plugin_id"] try: manifest = self._plugin_mgr.install_from_zip(zip_path) except Exception as exc: self._set_row_busy(pid, False, "Deps missing" if pm.get("kind") == "deps" else "Not installed") QMessageBox.critical(self, "Install Failed", str(exc)) return finally: if cleanup: _silent_remove(zip_path) if manifest.plugin_id != pid: self._set_row_busy(pid, False, "Deps missing" if pm.get("kind") == "deps" else "Not installed") QMessageBox.warning( self, "Wrong Plugin", f"Expected '{pid}' but the zip contains '{manifest.plugin_id}'." ) return remaining = self._plugin_mgr.get_missing_dependencies(pid) if remaining: self._set_row_busy(pid, False, "Deps still missing") QMessageBox.warning( self, "Dependencies Still Missing", "Plugin installed but these packages are still absent:\n\n" + "\n".join(f" {r}" for r in remaining) + "\n\nRe-zip the plugin with a vendor/ folder containing its dependencies." ) return self._status_lbls[pid].setText("✓ Ready") self._set_row_busy(pid, False, None) # None = keep status as-is if pid in self._install_btns: self._install_btns[pid].setEnabled(False) if pid in self._download_btns: self._download_btns[pid].setEnabled(False) self._refresh_continue_btn() # ── Helpers ─────────────────────────────────────────────────────────── def _set_row_busy(self, plugin_id: str, busy: bool, status_text: str | None): if status_text is not None: self._status_lbls[plugin_id].setText(status_text) for d in (self._install_btns, self._download_btns): btn = d.get(plugin_id) if btn: btn.setEnabled(not busy) QApplication.processEvents() def _refresh_continue_btn(self): installed_ids = {m.plugin_id for m in self._plugin_mgr.get_manifests()} all_resolved = all( pid in installed_ids and not self._plugin_mgr.get_missing_dependencies(pid) for pid in (pm["plugin_id"] for pm in self._missing) ) self._continue_btn.setText("Continue ✓" if all_resolved else "Continue") # ── Utilities ────────────────────────────────────────────────────────────────── def _hline() -> QFrame: f = QFrame(); f.setFrameShape(QFrame.Shape.HLine) return f def _silent_remove(path: str): try: os.unlink(path) except Exception: pass