From 0aa59c13af65beeae104662593b21ba4d8a37789 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Sat, 1 Aug 2026 19:06:36 -0600 Subject: Rename LabDAQ → LabUI and overhaul plugin system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 6 +- README.md | 4 +- api_layers/arduino_layer.py | 4 +- core/profile.py | 28 +++- core/updater.py | 4 +- docs/building.md | 113 ++++++++++++++++ docs/plugin-development.md | 10 +- labdaq.spec | 72 ----------- labui.spec | 72 +++++++++++ main.py | 9 +- plugins/__init__.py | 2 +- plugins/base_plugin.py | 8 +- plugins/motion_capture/manifest.json | 2 +- plugins/motion_capture/plugin.py | 4 +- plugins/plugin_manager.py | 151 ++++++++++++++++------ scripts/release/README.md | 6 +- scripts/release/repo_init.py | 4 +- scripts/release/repo_release.py | 10 +- ui/main_window.py | 103 +++++++++++++-- ui/profile_manager_ui.py | 10 +- ui/style.qss | 2 +- ui/style_dark.qss | 4 +- ui/style_light.qss | 3 +- ui/windows/missing_plugins_dialog.py | 242 +++++++++++++++++++++++++++++++++++ ui/windows/settings_window.py | 103 +++++++++++---- 25 files changed, 778 insertions(+), 198 deletions(-) create mode 100644 docs/building.md delete mode 100644 labdaq.spec create mode 100644 labui.spec create mode 100644 ui/windows/missing_plugins_dialog.py diff --git a/CLAUDE.md b/CLAUDE.md index 0fa549d..b95d079 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ Control widgets (left panel) go other direction: UI → `ControlWidget._write()` **Backend switching at runtime** — `AnalogInputDevice` and `DigitalIODevice` both have `switch_backend(backend, simulate, ...)` — disconnects, reconfigures, reconnects without restart. Called from "Apply & Reconnect" in config dialog. -**Profile persistence** — `core/profile.py` serialises full operator state (controls, plot layout, signal pipelines, derived channels) to `.labdaq` JSON files. `ProfileManager` handles save/load. +**Profile persistence** — `core/profile.py` serialises full operator state (controls, plot layout, signal pipelines, derived channels) to `.labui` JSON files. `ProfileManager` handles save/load. ### Directory map @@ -61,7 +61,7 @@ Control widgets (left panel) go other direction: UI → `ControlWidget._write()` | `devices/serial_device.py` | Generic UART device | | `core/acquisition.py` | `AcquisitionEngine` + `ChannelBuffer` | | `core/signal_processor.py` | Filter chain + derived/virtual channels | -| `core/profile.py` | `.labdaq` profile save/load | +| `core/profile.py` | `.labui` profile save/load | | `ui/main_window.py` | Top-level window, toolbar, demo device init, plugin lifecycle | | `ui/control_panel.py` | Left panel output widgets (`OnOffSwitch`, `MotorControl`, etc.) | | `ui/strip_chart.py` | Live pyqtgraph chart, config-driven by `LayoutConfig` | @@ -110,7 +110,7 @@ Optional hooks a plugin can implement: - `get_filter_classes() → dict` — added to `SignalProcessor` filter registry - `get_toolbar_actions() → list[PluginAction]` — buttons inserted in main toolbar - `get_settings_widget() → QWidget` — shown in Settings → Plugins panel -- `get_save_state() / apply_save_state(dict)` — persisted in `.labdaq` profiles +- `get_save_state() / apply_save_state(dict)` — persisted in `.labui` profiles **Extending Add Device dialog from a plugin**: `ui/add_device_dialog._PANELS` is a module-level dict `{type_name: (PanelClass, id_prefix)}`. Plugins add/remove entries in `on_load`/`on_unload`. Each panel class needs a `build_device(device_id) → BaseDevice` method. diff --git a/README.md b/README.md index 6ef85cc..bc13e54 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# LabDAQ — Modular Python DAQ Frontend +# LabUI — Modular Python DAQ Frontend A production-grade, modular PyQt6 data acquisition UI supporting NI-DAQmx and Arduino backends with live strip-chart plotting, CSV logging, and per-device configuration. @@ -17,7 +17,7 @@ python main.py pip install nidaqmx # also install NI-DAQmx runtime from ni.com # 4. For real Arduino hardware -# Upload devices/arduino_firmware/labdaq.ino to your board +# Upload devices/arduino_firmware/labui.ino to your board # Set port in device Configure dialog (e.g. COM3 / /dev/ttyUSB0) ``` diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py index 4f8cee4..96e0f5a 100644 --- a/api_layers/arduino_layer.py +++ b/api_layers/arduino_layer.py @@ -574,10 +574,10 @@ class ArduinoLayer: ARDUINO_FIRMWARE = r""" /* - * LabDAQ Arduino Firmware v1.1 + * LabUI Arduino Firmware v1.1 * ───────────────────────────────────────────────────────────────────────── * Upload this sketch to your Arduino. - * Set baud rate to 115200 in both this sketch and LabDAQ. + * Set baud rate to 115200 in both this sketch and LabUI. * * WHAT IT DOES * Continuously streams analog + digital readings to the PC. diff --git a/core/profile.py b/core/profile.py index 7ede859..8c73b4b 100644 --- a/core/profile.py +++ b/core/profile.py @@ -9,7 +9,7 @@ Lab profile — saves and loads the complete operator configuration: • Derived channels — all virtual/computed channels • Plot layout — pane arrangement, traces, axes, time window -Profiles are stored as human-readable JSON files (.labdaq). +Profiles are stored as human-readable JSON files (.labui). """ from __future__ import annotations @@ -80,6 +80,8 @@ class Profile: # plugin_state = {plugin_id: plugin.get_save_state()} plugins_enabled: List[str] = field(default_factory=list) # plugins_enabled = [plugin_id, ...] — which plugins were on when saved + plugins_manifest: List[Dict] = field(default_factory=list) + # plugins_manifest = [{plugin_id, name, version, description, requires, source_url}] script_vars: Dict[str, Any] = field(default_factory=dict) # script_vars — shared vars dict accessible as `vars` in all expressions @@ -99,9 +101,10 @@ class Profile: derived = d.get("derived", []), plot = d.get("plot"), settings = d.get("settings", {}), - plugin_state = d.get("plugin_state", {}), - plugins_enabled = d.get("plugins_enabled", []), - script_vars = d.get("script_vars", {}), + plugin_state = d.get("plugin_state", {}), + plugins_enabled = d.get("plugins_enabled", []), + plugins_manifest = d.get("plugins_manifest", []), + script_vars = d.get("script_vars", {}), ) def save(self, path: str): @@ -127,10 +130,10 @@ class ProfileManager: # Save profile = ProfileManager.capture(registry, processor, chart_cfg, control_specs, settings) - profile.save("my_lab.labdaq") + profile.save("my_lab.labui") # Load - profile = Profile.load("my_lab.labdaq") + profile = Profile.load("my_lab.labui") ProfileManager.apply(profile, registry, processor, ...) """ @@ -221,9 +224,20 @@ class ProfileManager: with processor._lock: p.script_vars = dict(processor._script_vars) - # Plugin state + enabled list + # Plugin state + enabled list + manifest snapshot if plugin_manager is not None: p.plugins_enabled = plugin_manager.get_enabled_ids() + for pid in p.plugins_enabled: + m = plugin_manager.get_manifest(pid) + if m: + p.plugins_manifest.append({ + "plugin_id": m.plugin_id, + "name": m.name, + "version": m.version, + "description": m.description, + "requires": m.requires, + "source_url": m.source_url, + }) for plugin in plugin_manager.get_loaded(): try: state = plugin.get_save_state() diff --git a/core/updater.py b/core/updater.py index d22cd42..471ef0b 100644 --- a/core/updater.py +++ b/core/updater.py @@ -21,7 +21,7 @@ from typing import Optional from core.app_settings import app_data_dir from core.version import __version__ as CURRENT_VERSION -APP_NAME = "labdaq" +APP_NAME = "labui" _RELEASE_BASE_URL = "https://github.com/c-kolset/labUI-python/releases/download/updates/" METADATA_BASE_URL = _RELEASE_BASE_URL @@ -50,7 +50,7 @@ def _bootstrap_root_json(metadata_dir: Path) -> None: remotely on first use, by design (TUF's root of trust has to come from somewhere already trusted, not from the same server being verified). - So the copy bundled into the app (labdaq.spec bundles + So the copy bundled into the app (labui.spec bundles scripts/release/repository/metadata/root.json to "repository/metadata") has to be seeded into metadata_dir before the first Client is ever constructed on a given machine. sys._MEIPASS is where PyInstaller puts diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 0000000..e20bcd3 --- /dev/null +++ b/docs/building.md @@ -0,0 +1,113 @@ +# Building LabUI from Source + +Produces a standalone `dist/LabUI/` directory (onedir, not onefile — required for tufup's per-file update patching). + +## Prerequisites + +Python 3.10+ and the dev requirements: + +```bash +pip install -r requirements.txt +pip install -r requirements-dev.txt +``` + +`requirements-dev.txt` adds PyInstaller and tufup on top of the runtime deps. + +## First-time setup — TUF repository + +Skip this if the `scripts/release/keystore/` directory already exists (i.e. someone else on the team has already initialised the repo and shared the keystore out-of-band). + +```bash +python scripts/release/repo_init.py +``` + +This generates: + +- `scripts/release/keystore/` — private + public keys for the TUF root/targets/snapshot/timestamp roles. **Back this up somewhere private outside the repo. Losing the root key means existing installs can never receive trusted updates.** +- `scripts/release/repository/` — initial signed TUF metadata, including `root.json`. + +After init, build once more so the freshly-generated `root.json` gets bundled into the app (the `.spec` file only includes it if it already exists): + +```bash +pyinstaller labui.spec +``` + +## Building a release + +### 1. Bump the version + +Edit `core/version.py`: + +```python +__version__ = "1.2.3" +``` + +This single value is read by the auto-updater at runtime (`core/updater.py`) and by the release script when signing the new target. + +### 2. Build the executable + +```bash +pyinstaller labui.spec +``` + +Output: `dist/LabUI/LabUI` (Linux/macOS) or `dist/LabUI/LabUI.exe` (Windows). + +The build bundles: +- `ui/style_dark.qss` and `ui/style_light.qss` +- `scripts/release/repository/metadata/root.json` (if present — enables trusted updates on fresh installs) + +Plugins are **not bundled**. They are installed at runtime by the user via **Settings → Plugins → Install Plugin…**. Installed plugins and their enabled state live in `~/.labui/plugins/`. Plugin pip dependencies (for machines without Python) land in `~/.labui/plugin_packages/`, which is added to `sys.path` at startup. + +### 3. Sign the release target + +```bash +python scripts/release/repo_release.py +``` + +Creates and signs `scripts/release/repository/targets/labui-.tar.gz` and updates the TUF metadata files in `scripts/release/repository/metadata/`. + +### 4. Publish to GitHub + +Upload every file from `scripts/release/repository/metadata/` and `scripts/release/repository/targets/` to the GitHub Release tagged **`updates`** (fixed tag — not a version tag), replacing any same-named files already there. + +The app's updater always points at `.../releases/download/updates/`, so the tag must stay constant across versions. + +Optionally create a separate human-facing release with a changelog under a version tag (e.g. `v1.2.3`). + +### 5. Verify + +Launch a previous installed version → Settings → Check for Updates → confirm it finds and applies the new version. + +## Re-signing expired metadata + +TUF metadata expires even with no new release (`root`: 365 days, others: 90 days). If metadata goes stale before the next release, clients reject it. Re-sign: + +```bash +python -m tufup sign snapshot scripts/release/keystore +python -m tufup sign timestamp scripts/release/keystore +``` + +Run from the repo root. Re-upload the resulting files to the `updates` release. + +## What's bundled vs. external + +| Item | Bundled in exe? | Notes | +|------|----------------|-------| +| Python runtime | Yes | PyInstaller includes it | +| PyQt6 / pyqtgraph / numpy | Yes | | +| QSS theme files | Yes | copied from `ui/` | +| Plugins | No | installed by users into `~/.labui/plugins/` at runtime | +| Plugin pip deps | No | installed into `~/.labui/plugin_packages/` or vendored inside plugin zip | +| `root.json` | Yes (if present) | TUF trust bootstrap | +| `nidaqmx` | No | optional; NI runtime must be installed separately | +| `opencv-python` | No | optional; only needed if a plugin requires it (vendor it in the plugin zip) | + +## Troubleshooting + +**`ModuleNotFoundError` at launch** — a hidden import PyInstaller missed. Add it to `hiddenimports` in `labui.spec` and rebuild. + +**Plugin not loading in built app** — plugins are not bundled; the user must install them via Settings → Plugins. If the plugin loads but fails silently, check that its `vendor/` folder contains all required packages, or that the user has pip-installed them (they land in `~/.labui/plugin_packages/`). + +**Plugin dependency not importable after pip install** — the user may need to restart the app so `~/.labui/plugin_packages/` is injected into `sys.path` (this happens in `main.py` at startup). + +**`root.json` not bundled** — run `repo_init.py` first (see First-time setup), then rebuild. diff --git a/docs/plugin-development.md b/docs/plugin-development.md index b0c3a63..8722376 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -1,4 +1,4 @@ -# LabDAQ Plugin Development Guide +# LabUI Plugin Development Guide Plugins live under `plugins/` as self-contained directories. The app discovers them automatically; the user enables or disables them in **Settings → Plugins**. A disabled plugin leaves zero trace in the UI. @@ -287,7 +287,7 @@ The widget is created once when the plugin loads. Apply changes immediately (no ### 5.5 Profile persistence -`.labdaq` profiles save the **enabled plugin list** automatically — loading a profile enables/disables plugins to match the saved state. Per-plugin configuration state is also saved if you implement these two methods. +`.labui` profiles save the **enabled plugin list** automatically — loading a profile enables/disables plugins to match the saved state. Per-plugin configuration state is also saved if you implement these two methods. ```python def get_save_state(self) -> dict: @@ -436,7 +436,7 @@ This plugin adds a sine-wave virtual channel and a toolbar button to toggle a di "name": "Sine Demo", "version": "1.0.0", "description": "Virtual sine-wave channel for testing.", - "author": "LabDAQ", + "author": "LabUI", "entry_point": "plugin.SineDemoPlugin" } ``` @@ -537,10 +537,10 @@ Import Qt widgets inside methods or inside `on_load`. This avoids import errors Stop background threads (`_running = False; _thread.join()`), disconnect signals, close windows. The app calls `on_unload()` both on user disable and on application close. **Filter `__init__` params must be JSON-serialisable.** -They are written into `.labdaq` profiles via `to_dict()` and reconstructed via `filter_from_dict()`. Stick to `int`, `float`, `str`, `bool`. +They are written into `.labui` profiles via `to_dict()` and reconstructed via `filter_from_dict()`. Stick to `int`, `float`, `str`, `bool`. **Profiles enable and disable plugins.** -Loading a `.labdaq` profile reconciles plugin state: plugins not in the profile's enabled list are disabled, plugins in the list are enabled. `apply_save_state` is called after the plugin is loaded. `plugins/enabled.json` is updated to match. +Loading a `.labui` profile reconciles plugin state: plugins not in the profile's enabled list are disabled, plugins in the list are enabled. `apply_save_state` is called after the plugin is loaded. `plugins/enabled.json` is updated to match. **Enabled state persists across restarts.** `plugins/enabled.json` is written every time a toggle changes. Delete it to reset all plugins to disabled. diff --git a/labdaq.spec b/labdaq.spec deleted file mode 100644 index 635af06..0000000 --- a/labdaq.spec +++ /dev/null @@ -1,72 +0,0 @@ -# labdaq.spec -# -# PyInstaller build spec. Build with: -# pyinstaller labdaq.spec -# -# Output: dist/LabDAQ/LabDAQ.exe (onedir build — required, not onefile: -# tufup replaces individual files in the install directory on update, -# which a single-file onefile exe can't do). - -import sys -from pathlib import Path - -block_cipher = None - -root_dir = Path(SPECPATH) - -# root.json is generated by scripts/release/repo_init.py — bundle it once it -# exists so a fresh install can bootstrap tufup trust without an insecure -# first fetch. Skip silently before that's ever been run (e.g. first build). -datas = [ - (str(root_dir / "ui" / "style_dark.qss"), "ui"), - (str(root_dir / "ui" / "style_light.qss"), "ui"), - (str(root_dir / "ui" / "style.qss"), "ui"), - (str(root_dir / "plugins"), "plugins"), -] -_root_json = root_dir / "scripts" / "release" / "repository" / "metadata" / "root.json" -if _root_json.exists(): - datas.append((str(_root_json), "repository/metadata")) - -a = Analysis( - ["main.py"], - pathex=[str(root_dir)], - binaries=[], - datas=datas, - hiddenimports=[], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False, -) -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - name="LabDAQ", - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - console=False, - disable_windowed_traceback=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) -coll = COLLECT( - exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name="LabDAQ", -) diff --git a/labui.spec b/labui.spec new file mode 100644 index 0000000..8468909 --- /dev/null +++ b/labui.spec @@ -0,0 +1,72 @@ +# labui.spec +# +# PyInstaller build spec. Build with: +# pyinstaller labui.spec +# +# Output: dist/LabUI/LabUI.exe (onedir build — required, not onefile: +# tufup replaces individual files in the install directory on update, +# which a single-file onefile exe can't do). + +import sys +from pathlib import Path + +block_cipher = None + +root_dir = Path(SPECPATH) + +# root.json is generated by scripts/release/repo_init.py — bundle it once it +# exists so a fresh install can bootstrap tufup trust without an insecure +# first fetch. Skip silently before that's ever been run (e.g. first build). +datas = [ + (str(root_dir / "ui" / "style_dark.qss"), "ui"), + (str(root_dir / "ui" / "style_light.qss"), "ui"), + (str(root_dir / "ui" / "style.qss"), "ui"), + (str(root_dir / "plugins"), "plugins"), +] +_root_json = root_dir / "scripts" / "release" / "repository" / "metadata" / "root.json" +if _root_json.exists(): + datas.append((str(_root_json), "repository/metadata")) + +a = Analysis( + ["main.py"], + pathex=[str(root_dir)], + binaries=[], + datas=datas, + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="LabUI", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="LabUI", +) diff --git a/main.py b/main.py index ede6277..96dab8a 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ """ -main.py — LabDAQ entry point. +main.py — LabUI entry point. Run: python main.py @@ -11,6 +11,11 @@ Requirements: import sys, os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +# Plugin dependencies installed at runtime land here so the frozen app can import them. +_PLUGIN_PACKAGES = os.path.join(os.path.expanduser("~"), ".labui", "plugin_packages") +if os.path.isdir(_PLUGIN_PACKAGES) and _PLUGIN_PACKAGES not in sys.path: + sys.path.insert(0, _PLUGIN_PACKAGES) + from PyQt6.QtWidgets import QApplication from ui.main_window import MainWindow from core.debug_log import install as install_debug_log @@ -18,7 +23,7 @@ from core.debug_log import install as install_debug_log def main(): app = QApplication(sys.argv) - app.setApplicationName("LabDAQ") + app.setApplicationName("LabUI") install_debug_log() # tee stdout/stderr for the Debug window, before anything prints qss = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "style_dark.qss") diff --git a/plugins/__init__.py b/plugins/__init__.py index bdb24e0..d1733a8 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -1,7 +1,7 @@ """ plugins/ -LabDAQ plugin system. Each plugin lives in its own subdirectory: +LabUI plugin system. Each plugin lives in its own subdirectory: plugins/ my_plugin/ diff --git a/plugins/base_plugin.py b/plugins/base_plugin.py index 2dbb56d..d728d08 100644 --- a/plugins/base_plugin.py +++ b/plugins/base_plugin.py @@ -1,7 +1,7 @@ """ plugins/base_plugin.py -Abstract base class for all LabDAQ plugins. +Abstract base class for all LabUI plugins. To create a plugin: 1. Create a directory under plugins/ e.g. plugins/my_plugin/ @@ -25,7 +25,7 @@ Integration points (all optional — override only what you need): get_devices() → list of BaseDevice auto-added to registry + engine get_filter_classes() → {name: FilterBase cls} custom signal pipeline filters get_settings_widget() → QWidget | None shown in Settings > Plugins - get_save_state() → dict persisted in .labdaq profiles + get_save_state() → dict persisted in .labui profiles apply_save_state(dict) restore from profile """ @@ -68,7 +68,7 @@ class PluginAction: class LabPlugin(ABC): """ - Abstract base for all LabDAQ plugins. + Abstract base for all LabUI plugins. Subclass this, set the metadata properties, and override whatever integration hooks your plugin needs. @@ -129,7 +129,7 @@ class LabPlugin(ABC): # ── Profile persistence ─────────────────────────────────────────────── def get_save_state(self) -> Dict[str, Any]: - """Return a JSON-serialisable dict, saved with the .labdaq profile.""" + """Return a JSON-serialisable dict, saved with the .labui profile.""" return {} def apply_save_state(self, state: Dict[str, Any]) -> None: diff --git a/plugins/motion_capture/manifest.json b/plugins/motion_capture/manifest.json index f1f10ce..59e5d02 100644 --- a/plugins/motion_capture/manifest.json +++ b/plugins/motion_capture/manifest.json @@ -3,7 +3,7 @@ "name": "Motion Capture", "version": "1.0.0", "description": "Track a point via webcam and stream x/y position as live signals. Apply pixel_to_mm filter to convert to real-world units.", - "author": "LabDAQ", + "author": "LabUI", "entry_point": "plugin.MotionCapturePlugin", "requires": ["opencv-python>=4.8.0"] } diff --git a/plugins/motion_capture/plugin.py b/plugins/motion_capture/plugin.py index 9cf9557..d6ca2b3 100644 --- a/plugins/motion_capture/plugin.py +++ b/plugins/motion_capture/plugin.py @@ -1,7 +1,7 @@ """ motion_capture/plugin.py -LabDAQ Motion Capture plugin. +LabUI Motion Capture plugin. When enabled, adds a "Camera" device type to the Add Device dialog. Each camera added by the user becomes a first-class device in the @@ -44,7 +44,7 @@ class MotionCapturePlugin(LabPlugin): ) @property - def author(self) -> str: return "LabDAQ" + def author(self) -> str: return "LabUI" # ── Lifecycle ───────────────────────────────────────────────────────── diff --git a/plugins/plugin_manager.py b/plugins/plugin_manager.py index 5cefc2e..4fceba2 100644 --- a/plugins/plugin_manager.py +++ b/plugins/plugin_manager.py @@ -1,7 +1,7 @@ """ plugins/plugin_manager.py -Discovers and manages the lifecycle of LabDAQ plugins. +Discovers and manages the lifecycle of LabUI plugins. Enabled state is persisted to plugins/enabled.json (one dict {plugin_id: bool}). This survives app restarts; profiles store @@ -39,6 +39,7 @@ class PluginManifest: entry_point: str = "plugin.Plugin" # "module.ClassName" relative to plugin dir requires: List[str] = field(default_factory=list) # pip-style reqs, e.g. "opencv-python>=4.8.0" plugin_dir: str = "" + source_url: str = "" def _dist_name(requirement: str) -> str: @@ -70,11 +71,11 @@ def missing_requirements(requires: List[str]) -> List[str]: class PluginManager: """ - Discovers, loads, and lifecycle-manages LabDAQ plugins. + Discovers, loads, and lifecycle-manages LabUI plugins. Typical usage in MainWindow: - self._plugins = PluginManager(plugins_dir) + self._plugins = PluginManager(user_dir, extra_scan_dirs=[project_plugins]) self._plugins.discover() for plugin in self._plugins.load_enabled(context): self._install_plugin(plugin) @@ -89,9 +90,10 @@ class PluginManager: self._uninstall_plugin("my_plugin") """ - def __init__(self, plugins_dir: str): - self._dir = plugins_dir - self._enabled_path = os.path.join(plugins_dir, _ENABLED_FILE) + def __init__(self, user_dir: str, extra_scan_dirs: List[str] = None): + self._user_dir = user_dir + self._scan_dirs = [user_dir] + list(extra_scan_dirs or []) + self._enabled_path = os.path.join(user_dir, _ENABLED_FILE) self._manifests: Dict[str, PluginManifest] = {} self._loaded: Dict[str, LabPlugin] = {} self._enabled: Dict[str, bool] = {} @@ -100,34 +102,36 @@ class PluginManager: # ── Discovery ───────────────────────────────────────────────────────── def discover(self) -> List[PluginManifest]: - """Scan plugin directory and return all found manifests.""" + """Scan all plugin directories and return all found manifests.""" self._manifests.clear() - if not os.path.isdir(self._dir): - return [] - - for entry in sorted(os.listdir(self._dir)): - plugin_dir = os.path.join(self._dir, entry) - if not os.path.isdir(plugin_dir): + for scan_dir in self._scan_dirs: + if not os.path.isdir(scan_dir): continue - manifest_path = os.path.join(plugin_dir, _MANIFEST_FILE) - if not os.path.isfile(manifest_path): - continue - try: - with open(manifest_path) as f: - data = json.load(f) - m = PluginManifest( - plugin_id = data["plugin_id"], - name = data.get("name", entry), - version = data.get("version", "1.0.0"), - description = data.get("description", ""), - author = data.get("author", ""), - entry_point = data.get("entry_point", "plugin.Plugin"), - requires = data.get("requires", []), - plugin_dir = plugin_dir, - ) - self._manifests[m.plugin_id] = m - except Exception as exc: - print(f"[PluginManager] Bad manifest in '{entry}': {exc}") + for entry in sorted(os.listdir(scan_dir)): + plugin_dir = os.path.join(scan_dir, entry) + if not os.path.isdir(plugin_dir): + continue + manifest_path = os.path.join(plugin_dir, _MANIFEST_FILE) + if not os.path.isfile(manifest_path): + continue + try: + with open(manifest_path) as f: + data = json.load(f) + m = PluginManifest( + plugin_id = data["plugin_id"], + name = data.get("name", entry), + version = data.get("version", "1.0.0"), + description = data.get("description", ""), + author = data.get("author", ""), + entry_point = data.get("entry_point", "plugin.Plugin"), + requires = data.get("requires", []), + plugin_dir = plugin_dir, + source_url = data.get("source_url", ""), + ) + if m.plugin_id not in self._manifests: + self._manifests[m.plugin_id] = m + except Exception as exc: + print(f"[PluginManager] Bad manifest in '{entry}': {exc}") return list(self._manifests.values()) @@ -142,7 +146,7 @@ class PluginManager: self._enabled = {} def _save_enabled_state(self): - os.makedirs(self._dir, exist_ok=True) + os.makedirs(self._user_dir, exist_ok=True) with open(self._enabled_path, "w") as f: json.dump(self._enabled, f, indent=2) @@ -175,7 +179,7 @@ class PluginManager: print(f"[PluginManager] No manifest for '{plugin_id}'") return None - missing = missing_requirements(manifest.requires) + missing = self.get_missing_dependencies(plugin_id) if missing: print(f"[Plugin] '{plugin_id}' missing dependencies: {', '.join(missing)}") return None @@ -193,9 +197,12 @@ class PluginManager: stage = "importing module" spec = importlib.util.spec_from_file_location( - f"_labdaq_plugin_{plugin_id}", module_file + f"_labui_plugin_{plugin_id}", module_file ) mod = importlib.util.module_from_spec(spec) + vendor = os.path.join(manifest.plugin_dir, "vendor") + if os.path.isdir(vendor) and vendor not in sys.path: + sys.path.insert(0, vendor) if manifest.plugin_dir not in sys.path: sys.path.insert(0, manifest.plugin_dir) spec.loader.exec_module(mod) @@ -252,12 +259,84 @@ class PluginManager: def get_manifests(self) -> List[PluginManifest]: return list(self._manifests.values()) + def get_manifest(self, plugin_id: str) -> Optional[PluginManifest]: + return self._manifests.get(plugin_id) + def get_missing_dependencies(self, plugin_id: str) -> List[str]: manifest = self._manifests.get(plugin_id) - return missing_requirements(manifest.requires) if manifest else [] + if not manifest: + return [] + vendor = os.path.join(manifest.plugin_dir, "vendor") + if os.path.isdir(vendor): + added = vendor not in sys.path + if added: + sys.path.insert(0, vendor) + try: + return missing_requirements(manifest.requires) + finally: + if added: + sys.path.remove(vendor) + return missing_requirements(manifest.requires) def get_loaded(self) -> List[LabPlugin]: return list(self._loaded.values()) def get_plugin(self, plugin_id: str) -> Optional[LabPlugin]: return self._loaded.get(plugin_id) + + # ── Install / uninstall ─────────────────────────────────────────────── + + def install_from_zip(self, zip_path: str) -> PluginManifest: + """Extract a plugin zip into user_dir and re-discover.""" + import shutil + import zipfile + + with zipfile.ZipFile(zip_path) as zf: + names = zf.namelist() + top_dirs = {n.split("/")[0] for n in names if "/" in n} + if not top_dirs: + raise ValueError("Plugin zip must contain a top-level directory") + if len(top_dirs) != 1: + raise ValueError( + f"Plugin zip must contain exactly one top-level directory, found: {sorted(top_dirs)}" + ) + plugin_folder = top_dirs.pop() + if f"{plugin_folder}/manifest.json" not in names: + raise ValueError(f"No {plugin_folder}/manifest.json found in zip") + dest = os.path.join(self._user_dir, plugin_folder) + if os.path.exists(dest): + shutil.rmtree(dest) + os.makedirs(self._user_dir, exist_ok=True) + zf.extractall(self._user_dir) + + self.discover() + for m in self._manifests.values(): + if os.path.basename(m.plugin_dir) == plugin_folder: + return m + raise ValueError("Plugin installed but not found after discovery") + + def uninstall(self, plugin_id: str): + """Delete a user-installed plugin from disk. Must be disabled first.""" + import shutil + + manifest = self._manifests.get(plugin_id) + if manifest is None: + raise ValueError(f"Plugin '{plugin_id}' not found") + if not self.is_user_installed(plugin_id): + raise ValueError(f"Plugin '{plugin_id}' is not user-installed and cannot be removed") + if self.is_enabled(plugin_id): + raise RuntimeError(f"Disable '{plugin_id}' before uninstalling") + shutil.rmtree(manifest.plugin_dir) + self._manifests.pop(plugin_id, None) + + def is_user_installed(self, plugin_id: str) -> bool: + """True if the plugin lives inside user_dir (i.e. can be uninstalled).""" + manifest = self._manifests.get(plugin_id) + if manifest is None: + return False + return os.path.abspath(manifest.plugin_dir).startswith( + os.path.abspath(self._user_dir) + ) + + def get_user_dir(self) -> str: + return self._user_dir diff --git a/scripts/release/README.md b/scripts/release/README.md index 6707ca1..54c9086 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -18,16 +18,16 @@ root/targets/snapshot/timestamp TUF roles) and `scripts/release/repository/` repo.** Losing the root key means you can never publish a trusted update to existing installs again — they'd all need a fresh, non-updating reinstall. -Rebuild once more (`pyinstaller labdaq.spec`) after this step, so the +Rebuild once more (`pyinstaller labui.spec`) after this step, so the freshly-generated `root.json` gets bundled into the app (see the `.spec` file's `_root_json` check — it only bundles the file if it already exists). ## Every release 1. Bump `__version__` in `core/version.py`. -2. `pyinstaller labdaq.spec` → produces `dist/LabDAQ/`. +2. `pyinstaller labui.spec` → produces `dist/LabUI/`. 3. `python scripts/release/repo_release.py` → creates+signs - `scripts/release/repository/targets/labdaq-.tar.gz` and updates + `scripts/release/repository/targets/labui-.tar.gz` and updates the metadata files in `scripts/release/repository/metadata/`. 4. Create (first time) or reuse the GitHub Release tagged **`updates`** on this repo, and upload every file from `scripts/release/repository/metadata/` diff --git a/scripts/release/repo_init.py b/scripts/release/repo_init.py index 04c3579..ccbfe17 100644 --- a/scripts/release/repo_init.py +++ b/scripts/release/repo_init.py @@ -3,7 +3,7 @@ scripts/release/repo_init.py Run ONCE, ever, by the maintainer, locally. Generates the TUF signing keys and the initial repository metadata (including root.json, which later gets -bundled into the app via labdaq.spec). +bundled into the app via labui.spec). Running this again after the repo already exists is safe (tufup skips re-creating keys/roles that already exist) but there is normally no reason @@ -24,7 +24,7 @@ from tufup.repo import Repository, RolesDict # directory, so invocation location must be consistent every time. os.chdir(Path(__file__).resolve().parent) -APP_NAME = "labdaq" +APP_NAME = "labui" # tufup-example's 1-day timestamp / 7-day snapshot defaults assume an # automated CI worker re-signs on a schedule. We're signing manually diff --git a/scripts/release/repo_release.py b/scripts/release/repo_release.py index a3c8d03..d50e458 100644 --- a/scripts/release/repo_release.py +++ b/scripts/release/repo_release.py @@ -1,14 +1,14 @@ """ scripts/release/repo_release.py -Run after every `pyinstaller labdaq.spec` build, from anywhere (paths are +Run after every `pyinstaller labui.spec` build, from anywhere (paths are made absolute below before the working directory changes). Packages the -freshly built dist/LabDAQ bundle as a new signed tufup target. +freshly built dist/LabUI bundle as a new signed tufup target. Prerequisites: - repo_init.py has been run once already (./repository and ./keystore exist) - core/version.py has been bumped to the new version - - dist/LabDAQ/ exists (pyinstaller labdaq.spec has just been run) + - dist/LabUI/ exists (pyinstaller labui.spec has just been run) After this completes, see README.md in this directory for how to publish the updated ./repository/metadata and ./repository/targets files to the @@ -21,7 +21,7 @@ from pathlib import Path _THIS_DIR = Path(__file__).resolve().parent _REPO_ROOT = _THIS_DIR.parent.parent -_DIST_DIR = _REPO_ROOT / "dist" / "LabDAQ" +_DIST_DIR = _REPO_ROOT / "dist" / "LabUI" # Import core.version before changing cwd — needs the repo root on sys.path, # which won't be true anymore once we chdir into scripts/release below. @@ -38,7 +38,7 @@ os.chdir(_THIS_DIR) def main(): if not _DIST_DIR.is_dir(): raise SystemExit( - f"{_DIST_DIR} not found — run `pyinstaller labdaq.spec` from the " + f"{_DIST_DIR} not found — run `pyinstaller labui.spec` from the " f"repo root first." ) 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) diff --git a/ui/profile_manager_ui.py b/ui/profile_manager_ui.py index 8966f8f..446e867 100644 --- a/ui/profile_manager_ui.py +++ b/ui/profile_manager_ui.py @@ -17,8 +17,8 @@ from PyQt6.QtGui import QAction from core.profile import Profile, ProfileManager -PROFILE_EXT = ".labdaq" -PROFILE_FILTER = f"LabDAQ Profile (*{PROFILE_EXT});;All files (*)" +PROFILE_EXT = ".labui" +PROFILE_FILTER = f"LabUI Profile (*{PROFILE_EXT});;All files (*)" class ProfileButton(QPushButton): @@ -134,7 +134,7 @@ class ProfileButton(QPushButton): # ── Helpers ─────────────────────────────────────────────────────────────── def _default_dir(self) -> str: - d = os.path.join(os.path.expanduser("~"), "labdaq_profiles") + d = os.path.join(os.path.expanduser("~"), "labui_profiles") os.makedirs(d, exist_ok=True) return d @@ -142,8 +142,8 @@ class ProfileButton(QPushButton): app = QApplication.instance() if app and hasattr(app, "topLevelWidgets"): for w in app.topLevelWidgets(): - if hasattr(w, "setWindowTitle") and "LabDAQ" in (w.windowTitle() or ""): - title = "LabDAQ" + if hasattr(w, "setWindowTitle") and "LabUI" in (w.windowTitle() or ""): + title = "LabUI" if name: title += f" — {name}" if self._current_path: diff --git a/ui/style.qss b/ui/style.qss index 3761c68..aa11afb 100644 --- a/ui/style.qss +++ b/ui/style.qss @@ -1,4 +1,4 @@ -/* LabDAQ — Industrial Dark Theme +/* LabUI — Industrial Dark Theme Font stack: IBM Plex Mono (monospace data), IBM Plex Sans (UI labels) Palette: bg-base #0b0e13 diff --git a/ui/style_dark.qss b/ui/style_dark.qss index 9fd7198..431459d 100644 --- a/ui/style_dark.qss +++ b/ui/style_dark.qss @@ -1,4 +1,4 @@ -/* LabDAQ — Industrial Dark Theme +/* LabUI — Industrial Dark Theme Palette: bg-base #0b0e13 bg-panel #111620 bg-card #161d2e bg-raised #1c2540 border #2a3558 @@ -384,6 +384,8 @@ QStatusBar { color: #8b9dc3; font-family: "IBM Plex Mono", monospace; font-size: 12px; } +QLabel#versionLabel { font-family: "IBM Plex Mono", monospace; font-size: 10px; color: #8b9dc3; padding-right: 6px; } + /* ── Default button ───────────────────────────────────────────────── */ QPushButton { background-color: #1c2540; color: #8b9dc3; diff --git a/ui/style_light.qss b/ui/style_light.qss index 265894d..ff204fb 100644 --- a/ui/style_light.qss +++ b/ui/style_light.qss @@ -1,4 +1,4 @@ -/* LabDAQ — Light Theme */ +/* LabUI — Light Theme */ * { font-family:"IBM Plex Sans","Segoe UI",Tahoma,sans-serif; font-size:12px; color:#1e293b; } QMainWindow,QDialog { background:#f8fafc; } QToolBar#mainToolbar { background:#ffffff; border-bottom:1px solid #e2e8f0; padding:4px 8px; spacing:4px; } @@ -49,6 +49,7 @@ QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical { height:0; } QSplitter::handle { background:#e2e8f0; } QSplitter::handle:hover { background:#3b82f6; } QStatusBar { background:#ffffff; border-top:1px solid #e2e8f0; color:#64748b; font-family:"IBM Plex Mono",monospace; font-size:11px; } +QLabel#versionLabel { font-family:"IBM Plex Mono",monospace; font-size:10px; color:#64748b; padding-right:6px; } QPushButton { background:#f1f5f9; color:#475569; border:1px solid #cbd5e1; border-radius:4px; padding:5px 12px; } QPushButton:hover { background:#e2e8f0; color:#1e293b; } QTextEdit#codeEditor { font-family:"IBM Plex Mono",monospace; font-size:12px; background:#1e293b; color:#e2e8f0; border:1px solid #cbd5e1; border-radius:4px; padding:6px; } 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 Continue 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"{pm.get('name', pm['plugin_id'])}" + f" v{pm.get('version', '?')}") + 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"{manifest.name} v{manifest.version}") 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): -- cgit v1.2.3