diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-08-01 19:06:36 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-08-01 19:06:36 -0600 |
| commit | 0aa59c13af65beeae104662593b21ba4d8a37789 (patch) | |
| tree | 17a1e6bd92a3a236bef0bca2e11ca7a875ba6caa /ui/main_window.py | |
| parent | bfbdd0c19910f464e779fa64cc0ec8590f8e37c1 (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.py | 103 |
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) |
