summaryrefslogtreecommitdiff
path: root/ui/main_window.py
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/main_window.py
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/main_window.py')
-rw-r--r--ui/main_window.py103
1 files changed, 90 insertions, 13 deletions
diff --git a/ui/main_window.py b/ui/main_window.py
index 9090d0f..efbb486 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -55,8 +55,7 @@ _LIGHT_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_lig
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
- from core.version import __version__
- self.setWindowTitle(f"LabDAQ v{__version__}")
+ self.setWindowTitle("LabUI")
self.setMinimumSize(1000, 640)
self.registry = DeviceRegistry()
@@ -71,9 +70,14 @@ class MainWindow(QMainWindow):
self._win_settings = None
self._win_debug = None
- _plugins_dir = os.path.join(os.path.dirname(os.path.dirname(
- os.path.abspath(__file__))), "plugins")
- self._plugin_mgr = PluginManager(_plugins_dir)
+ import sys as _sys
+ _user_plugins = os.path.join(os.path.expanduser("~"), ".labui", "plugins")
+ _extra_dirs = []
+ if not getattr(_sys, "frozen", False):
+ _project_plugins = os.path.join(os.path.dirname(os.path.dirname(
+ os.path.abspath(__file__))), "plugins")
+ _extra_dirs = [_project_plugins]
+ self._plugin_mgr = PluginManager(_user_plugins, extra_scan_dirs=_extra_dirs)
self._plugin_mgr.discover()
# {plugin_id: [QAction, ...]} toolbar actions to remove on unload
self._plugin_toolbar_actions: dict = {}
@@ -200,6 +204,10 @@ class MainWindow(QMainWindow):
sb = QStatusBar(); self.setStatusBar(sb)
self._status = QLabel("Ready"); sb.addWidget(self._status)
self._log_lbl = QLabel(""); sb.addPermanentWidget(self._log_lbl)
+ from core.version import __version__
+ _ver_lbl = QLabel(f"v{__version__}")
+ _ver_lbl.setObjectName("versionLabel")
+ sb.addPermanentWidget(_ver_lbl)
self._clock = QTimer(self); self._clock.setInterval(1000)
self._clock.timeout.connect(self._tick)
@@ -333,27 +341,55 @@ class MainWindow(QMainWindow):
self._install_plugin(plugin)
def _pip_install(self, requirements: list) -> bool:
- """Blocking `pip install` of the given requirement strings.
- Returns True on success; shows a result dialog either way."""
+ """Blocking pip install of the given requirement strings.
+
+ In a frozen (PyInstaller) app sys.executable is the exe itself, so we
+ locate a real Python interpreter and install into ~/.labui/plugin_packages/
+ which is added to sys.path at startup (see main.py). In dev mode the
+ normal sys.executable + site-packages path is used instead.
+ Returns True on success; shows a result dialog either way.
+ """
+ import shutil
import subprocess
- import sys
+ import sys as _sys
from PyQt6.QtWidgets import QMessageBox
+ frozen = getattr(_sys, "frozen", False)
+
+ if frozen:
+ python = shutil.which("python3") or shutil.which("python")
+ if not python:
+ QMessageBox.critical(
+ self, "Install Failed",
+ "Could not find a Python interpreter on PATH.\n"
+ "Install the required packages manually:\n\n"
+ + "\n".join(f" pip install {r}" for r in requirements)
+ )
+ return False
+ pkg_dir = os.path.join(os.path.expanduser("~"), ".labui", "plugin_packages")
+ os.makedirs(pkg_dir, exist_ok=True)
+ cmd = [python, "-m", "pip", "install", "--target", pkg_dir, *requirements]
+ else:
+ python = _sys.executable
+ cmd = [python, "-m", "pip", "install", *requirements]
+
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
- result = subprocess.run(
- [sys.executable, "-m", "pip", "install", *requirements],
- capture_output=True, text=True,
- )
+ result = subprocess.run(cmd, capture_output=True, text=True)
finally:
QApplication.restoreOverrideCursor()
if result.returncode == 0:
+ if frozen:
+ pkg_dir = os.path.join(os.path.expanduser("~"), ".labui", "plugin_packages")
+ if pkg_dir not in _sys.path:
+ _sys.path.insert(0, pkg_dir)
QMessageBox.information(
self, "Install Complete",
f"Installed: {', '.join(requirements)}"
)
return True
+
QMessageBox.critical(
self, "Install Failed",
f"pip install failed for: {', '.join(requirements)}\n\n"
@@ -516,12 +552,53 @@ class MainWindow(QMainWindow):
plugin_manager=self._plugin_mgr,
)
+ def _check_profile_plugins(self, profile: Profile) -> bool:
+ """Detect missing/broken plugins and prompt user to fix them.
+ Returns False if the user cancels the profile load.
+
+ Two cases are caught:
+ 'plugin' — plugin not installed at all
+ 'deps' — plugin installed but its dependencies are absent
+ """
+ if not profile.plugins_enabled:
+ return True
+
+ installed_ids = {m.plugin_id for m in self._plugin_mgr.get_manifests()}
+ missing = []
+ for pid in profile.plugins_enabled:
+ pm_info = next(
+ (m for m in profile.plugins_manifest if m.get("plugin_id") == pid),
+ {"plugin_id": pid, "name": pid, "version": "unknown",
+ "description": "", "requires": []},
+ )
+ if pid not in installed_ids:
+ missing.append({**pm_info, "kind": "plugin"})
+ else:
+ absent_deps = self._plugin_mgr.get_missing_dependencies(pid)
+ if absent_deps:
+ missing.append({**pm_info, "kind": "deps",
+ "missing_deps": absent_deps})
+
+ if not missing:
+ return True
+
+ from PyQt6.QtWidgets import QDialog
+ from ui.windows.missing_plugins_dialog import MissingPluginsDialog
+ dlg = MissingPluginsDialog(missing, self._plugin_mgr, self)
+ return dlg.exec() == QDialog.DialogCode.Accepted
+
def _profile_apply(self, profile: Profile):
"""Restore state from a Profile object."""
+ if not self._check_profile_plugins(profile):
+ return
+
# Reconcile plugin enabled state before the rest of apply runs,
# so plugin devices are present when channels/pipelines are restored.
+ # Only enable plugins that are actually installed — missing ones were
+ # handled (or skipped) by _check_profile_plugins.
if profile.plugins_enabled is not None:
- wanted = set(profile.plugins_enabled)
+ installed_ids = {m.plugin_id for m in self._plugin_mgr.get_manifests()}
+ wanted = set(profile.plugins_enabled) & installed_ids
current = set(self._plugin_mgr.get_enabled_ids())
for pid in current - wanted:
self.plugin_disable(pid)