summaryrefslogtreecommitdiff
path: root/ui/windows
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-08-01 19:06:36 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-08-01 19:06:36 -0600
commit0aa59c13af65beeae104662593b21ba4d8a37789 (patch)
tree17a1e6bd92a3a236bef0bca2e11ca7a875ba6caa /ui/windows
parentbfbdd0c19910f464e779fa64cc0ec8590f8e37c1 (diff)
Rename LabDAQ → LabUI and overhaul plugin system
Branding: - Rename app, window title, file extension (.labdaq → .labui), user data dirs (~/.labui/), spec file (labdaq.spec → labui.spec), and APP_NAME throughout all source, docs, and config files Plugin system: - Plugins no longer bundled in the PyInstaller build — installed at runtime by users via Settings → Plugins → Install Plugin (zip) - PluginManager now takes user_dir + extra_scan_dirs; user plugins live in ~/.labui/plugins/, dev scan additionally covers project plugins/ - install_from_zip / uninstall / is_user_installed added to PluginManager - vendor/ dir inside plugin zips: prepended to sys.path at load time so plugins can ship their own deps without requiring pip on end-user machine - source_url field in manifest.json: shown as Download button in the missing-plugins dialog when a profile requires an absent plugin - Frozen-app pip install now targets ~/.labui/plugin_packages/ using a real system Python (sys.executable is the exe in frozen builds) Profile loading: - Profile now stores plugins_manifest snapshot (id, name, version, source_url) alongside plugins_enabled - On load, missing or dep-broken plugins trigger MissingPluginsDialog before the rest of the profile is applied; user can install from zip or download via source_url in-dialog, or cancel the load - Plugin reconciliation only enables installed plugins — missing ones are not written to enabled.json UI: - Version label added to status bar (bottom-right, muted colour) - Settings → Plugins tab: Install Plugin… button, per-plugin Remove button for user-installed plugins, live list refresh after install/remove Docs: - New docs/building.md covers the full release pipeline - docs/plugin-development.md updated for new install flow, vendoring, source_url, profile behaviour, and distribution instructions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'ui/windows')
-rw-r--r--ui/windows/missing_plugins_dialog.py242
-rw-r--r--ui/windows/settings_window.py103
2 files changed, 317 insertions, 28 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
diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py
index 75b6991..96a99a5 100644
--- a/ui/windows/settings_window.py
+++ b/ui/windows/settings_window.py
@@ -52,6 +52,7 @@ class SettingsWindow(QWidget):
self.engine = engine
self._plugin_mgr = plugin_manager
self._plugin_buttons: dict = {} # plugin_id -> QPushButton
+ self._plugins_scroll = None
self.cfg = dict(self._defaults)
if current:
self.cfg.update(current)
@@ -207,48 +208,57 @@ class SettingsWindow(QWidget):
def _plugins_tab(self):
w = QWidget()
+ vl = QVBoxLayout(w); vl.setContentsMargins(0,0,0,0); vl.setSpacing(0)
+
+ # Install bar
+ bar = QWidget(); bar.setObjectName("cfgBottomBar")
+ bl = QHBoxLayout(bar); bl.setContentsMargins(12,6,12,6)
+ bl.addStretch()
+ inst_btn = QPushButton("Install Plugin…"); inst_btn.setObjectName("configButton")
+ inst_btn.clicked.connect(self._install_plugin_from_file)
+ bl.addWidget(inst_btn)
+ vl.addWidget(bar)
+
scroll = QScrollArea(); scroll.setWidgetResizable(True)
scroll.setObjectName("deviceScroll")
- cont = QWidget(); lay = QVBoxLayout(cont)
+ self._plugins_scroll = scroll
+ vl.addWidget(scroll, 1)
+ self._refresh_plugins_list()
+ return w
+
+ def _refresh_plugins_list(self):
+ self._plugin_buttons.clear()
+ cont = QWidget()
+ lay = QVBoxLayout(cont)
lay.setContentsMargins(14, 12, 14, 12); lay.setSpacing(10)
if self._plugin_mgr is None:
lay.addWidget(QLabel("Plugin manager not available."))
lay.addStretch()
- scroll.setWidget(cont)
- root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0)
- root.addWidget(scroll); return w
+ self._plugins_scroll.setWidget(cont)
+ return
manifests = self._plugin_mgr.get_manifests()
-
if not manifests:
info = QLabel(
- "No plugins found.\n\n"
- "Drop a plugin folder into the plugins/ directory next to main.py.\n"
- "Each plugin needs a manifest.json and a plugin.py."
+ "No plugins installed.\n\n"
+ "Click 'Install Plugin…' above to install a plugin from a .zip file."
)
info.setObjectName("traceSource"); info.setWordWrap(True)
lay.addWidget(info)
lay.addStretch()
- scroll.setWidget(cont)
- root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0)
- root.addWidget(scroll); return w
+ self._plugins_scroll.setWidget(cont)
+ return
for manifest in manifests:
lay.addWidget(self._plugin_card(manifest))
-
lay.addStretch()
- scroll.setWidget(cont)
- root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0)
- root.addWidget(scroll); return w
+ self._plugins_scroll.setWidget(cont)
def _plugin_card(self, manifest):
- """One card per discovered plugin."""
- card = QGroupBox()
- card.setObjectName("pluginCard")
+ card = QGroupBox(); card.setObjectName("pluginCard")
cl = QVBoxLayout(card); cl.setContentsMargins(10, 8, 10, 8); cl.setSpacing(4)
- # Header row: name + version + enable toggle
hdr = QHBoxLayout()
name_lbl = QLabel(f"<b>{manifest.name}</b> <small>v{manifest.version}</small>")
name_lbl.setObjectName("traceLabel")
@@ -256,33 +266,36 @@ class SettingsWindow(QWidget):
enabled = self._plugin_mgr.is_enabled(manifest.plugin_id)
toggle = QPushButton("Disable" if enabled else "Enable")
- toggle.setObjectName("configButton")
- toggle.setFixedWidth(72)
+ toggle.setObjectName("configButton"); toggle.setFixedWidth(72)
toggle.clicked.connect(
lambda _, pid=manifest.plugin_id, btn=toggle: self._toggle_plugin(pid, btn)
)
self._plugin_buttons[manifest.plugin_id] = toggle
hdr.addWidget(toggle)
+
+ if self._plugin_mgr.is_user_installed(manifest.plugin_id):
+ rm_btn = QPushButton("Remove")
+ rm_btn.setObjectName("configButton"); rm_btn.setFixedWidth(72)
+ rm_btn.clicked.connect(
+ lambda _, pid=manifest.plugin_id, pname=manifest.name: self._remove_plugin(pid, pname)
+ )
+ hdr.addWidget(rm_btn)
+
cl.addLayout(hdr)
- # Description / author
if manifest.description:
desc = QLabel(manifest.description)
desc.setObjectName("traceSource"); desc.setWordWrap(True)
cl.addWidget(desc)
-
if manifest.author:
- author = QLabel(f"Author: {manifest.author}")
- author.setObjectName("traceSource")
- cl.addWidget(author)
+ cl.addWidget(QLabel(f"Author: {manifest.author}").also(
+ lambda w: w.setObjectName("traceSource")))
- # Plugin-specific settings widget (only when loaded)
plugin = self._plugin_mgr.get_plugin(manifest.plugin_id)
if plugin:
sw = plugin.get_settings_widget()
if sw is not None:
cl.addWidget(sw)
-
return card
def _toggle_plugin(self, plugin_id: str, btn: QPushButton):
@@ -301,6 +314,40 @@ class SettingsWindow(QWidget):
if btn is not None and self._plugin_mgr is not None:
btn.setText("Disable" if self._plugin_mgr.is_enabled(plugin_id) else "Enable")
+ def _install_plugin_from_file(self):
+ path, _ = QFileDialog.getOpenFileName(
+ self, "Install Plugin", "", "Plugin Archives (*.zip)"
+ )
+ if not path:
+ return
+ try:
+ manifest = self._plugin_mgr.install_from_zip(path)
+ self._refresh_plugins_list()
+ QMessageBox.information(
+ self, "Plugin Installed",
+ f"'{manifest.name}' v{manifest.version} installed successfully."
+ )
+ except Exception as exc:
+ QMessageBox.critical(self, "Install Failed", str(exc))
+
+ def _remove_plugin(self, plugin_id: str, plugin_name: str):
+ if self._plugin_mgr.is_enabled(plugin_id):
+ QMessageBox.warning(self, "Cannot Remove",
+ "Disable the plugin before removing it.")
+ return
+ reply = QMessageBox.question(
+ self, "Remove Plugin",
+ f"Remove '{plugin_name}'? This deletes the plugin files.",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ )
+ if reply == QMessageBox.StandardButton.Yes:
+ try:
+ self._plugin_mgr.uninstall(plugin_id)
+ self._refresh_plugins_list()
+ except Exception as exc:
+ QMessageBox.critical(self, "Remove Failed", str(exc))
+
# ── Actions ───────────────────────────────────────────────────────────
def _check_for_updates(self):