diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-08-02 11:05:48 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-08-02 11:05:48 -0600 |
| commit | 1c349a982026736f4c2a26951d9d0aefe7b963bc (patch) | |
| tree | b66175df392e3d707ca45650facfd18bcba6c337 /ui/windows/missing_plugins_dialog.py | |
| parent | fa0304793a4525db68cb53b527d695bfa2c65966 (diff) | |
| parent | 0aa59c13af65beeae104662593b21ba4d8a37789 (diff) | |
Merge branch 'feat/auto-updater'
Diffstat (limited to 'ui/windows/missing_plugins_dialog.py')
| -rw-r--r-- | ui/windows/missing_plugins_dialog.py | 242 |
1 files changed, 242 insertions, 0 deletions
diff --git a/ui/windows/missing_plugins_dialog.py b/ui/windows/missing_plugins_dialog.py new file mode 100644 index 0000000..4a183d3 --- /dev/null +++ b/ui/windows/missing_plugins_dialog.py @@ -0,0 +1,242 @@ +""" +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 <b>Continue</b> 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"<b>{pm.get('name', pm['plugin_id'])}</b>" + f" <small>v{pm.get('version', '?')}</small>") + 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 |
