1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
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
|