summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--CLAUDE.md6
-rw-r--r--README.md4
-rw-r--r--core/app_settings.py12
-rw-r--r--core/profile.py28
-rw-r--r--core/updater.py107
-rw-r--r--core/version.py12
-rw-r--r--docs/building.md113
-rw-r--r--docs/plugin-development.md10
-rw-r--r--labui.spec72
-rw-r--r--main.py10
-rw-r--r--plugins/__init__.py2
-rw-r--r--plugins/base_plugin.py8
-rw-r--r--plugins/motion_capture/manifest.json2
-rw-r--r--plugins/motion_capture/plugin.py4
-rw-r--r--plugins/plugin_manager.py151
-rw-r--r--requirements-dev.txt3
-rw-r--r--scripts/release/README.md61
-rw-r--r--scripts/release/repo_init.py57
-rw-r--r--scripts/release/repo_release.py64
-rw-r--r--ui/main_window.py102
-rw-r--r--ui/profile_manager_ui.py10
-rw-r--r--ui/style.qss2
-rw-r--r--ui/style_dark.qss4
-rw-r--r--ui/style_light.qss3
-rw-r--r--ui/windows/missing_plugins_dialog.py242
-rw-r--r--ui/windows/settings_window.py159
27 files changed, 1139 insertions, 114 deletions
diff --git a/.gitignore b/.gitignore
index b4f9947..fcd69c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,12 +4,15 @@ __pycache__/
*.pyc
*.pyo
-# Build artifacts
+# PyInstaller build output
build/
dist/
# TUF release keystore (private signing keys)
+# generated repository/ is republished to GitHub Releases, not tracked here
scripts/release/keystore/
+scripts/release/repository/
+scripts/release/.tufup-repo-config
# Claude project memory
.claude/
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/core/app_settings.py b/core/app_settings.py
index ea3ac49..718a768 100644
--- a/core/app_settings.py
+++ b/core/app_settings.py
@@ -18,14 +18,22 @@ import sys
from pathlib import Path
-def _settings_path() -> Path:
+def app_data_dir() -> Path:
+ """Per-user, per-platform base directory for anything this app needs to
+ persist outside the install directory (settings, updater cache, ...) —
+ deliberately outside the install dir since an update can replace/move
+ everything in there."""
if sys.platform == "win32":
base = Path(os.environ.get("APPDATA", Path.home()))
elif sys.platform == "darwin":
base = Path.home() / "Library" / "Application Support"
else:
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
- return base / "labUI" / "settings.json"
+ return base / "labUI"
+
+
+def _settings_path() -> Path:
+ return app_data_dir() / "settings.json"
SETTINGS_PATH = _settings_path()
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
new file mode 100644
index 0000000..471ef0b
--- /dev/null
+++ b/core/updater.py
@@ -0,0 +1,107 @@
+"""
+core/updater.py
+
+Thin wrapper around tufup.client.Client — checks for and applies app
+updates. Only meaningful inside a PyInstaller-frozen build (see
+is_frozen()); calling check_for_update()/apply_update() from a
+`python main.py` dev run raises, since there's no installed bundle for
+tufup to update.
+
+Update metadata/targets are hosted on this repo's "updates" GitHub Release
+— a fixed tag that never changes between app versions, unlike a normal
+per-version release tag, because TUF's top-level metadata (timestamp.json,
+snapshot.json, ...) must live at a stable URL the client always checks.
+See scripts/release/README.md for the publishing side.
+"""
+
+import sys
+from pathlib import Path
+from typing import Optional
+
+from core.app_settings import app_data_dir
+from core.version import __version__ as CURRENT_VERSION
+
+APP_NAME = "labui"
+
+_RELEASE_BASE_URL = "https://github.com/c-kolset/labUI-python/releases/download/updates/"
+METADATA_BASE_URL = _RELEASE_BASE_URL
+TARGET_BASE_URL = _RELEASE_BASE_URL
+
+_client = None # lazily-created tufup.client.Client, reused across calls so
+ # apply_update() can act on what check_for_update() found
+
+
+def is_frozen() -> bool:
+ """True only inside a PyInstaller-built exe — never for `python main.py`."""
+ return bool(getattr(sys, "frozen", False))
+
+
+def _require_frozen():
+ if not is_frozen():
+ raise RuntimeError(
+ "Updates are only available in the packaged app, not `python main.py`."
+ )
+
+
+def _bootstrap_root_json(metadata_dir: Path) -> None:
+ """
+ tuf.ngclient.Updater (tufup.client.Client's base class) loads root.json
+ from metadata_dir automatically on construction — it does NOT fetch it
+ 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 (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
+ bundled data files at runtime, for both onefile and onedir builds.
+ """
+ dest = metadata_dir / "root.json"
+ if dest.exists():
+ return
+ bundled = Path(getattr(sys, "_MEIPASS", "")) / "repository" / "metadata" / "root.json"
+ if not bundled.is_file():
+ raise RuntimeError(f"Bundled root.json not found at {bundled} — was the app built "
+ f"before scripts/release/repo_init.py had been run?")
+ metadata_dir.mkdir(parents=True, exist_ok=True)
+ dest.write_bytes(bundled.read_bytes())
+
+
+def _get_client():
+ global _client
+ if _client is None:
+ from tufup.client import Client
+
+ cache_dir = app_data_dir() / "tufup"
+ metadata_dir = cache_dir / "metadata"
+ _bootstrap_root_json(metadata_dir)
+ _client = Client(
+ app_name=APP_NAME,
+ app_install_dir=Path(sys.executable).resolve().parent,
+ current_version=CURRENT_VERSION,
+ metadata_dir=metadata_dir,
+ metadata_base_url=METADATA_BASE_URL,
+ target_dir=cache_dir / "targets",
+ target_base_url=TARGET_BASE_URL,
+ )
+ return _client
+
+
+def check_for_update() -> Optional[str]:
+ """Return the new version string if an update is available, else None."""
+ _require_frozen()
+ new_target = _get_client().check_for_updates()
+ return str(new_target.version) if new_target else None
+
+
+def apply_update() -> None:
+ """
+ Download, TUF-verify, and install the update found by the most recent
+ check_for_update() call on this process. Only call after
+ check_for_update() returned a version — tufup raises otherwise.
+
+ tufup's default install step replaces the app's files and relaunches it;
+ the calling UI should assume the process may exit during this call.
+ """
+ _require_frozen()
+ _get_client().download_and_apply_update(skip_confirmation=True)
diff --git a/core/version.py b/core/version.py
new file mode 100644
index 0000000..8b6c492
--- /dev/null
+++ b/core/version.py
@@ -0,0 +1,12 @@
+"""
+core/version.py
+
+Single source of truth for the app version.
+
+Bump this before cutting a release — scripts/release/repo_release.py reads
+it (via Repository's app_version_attr="core.version.__version__") to tag
+the new tufup target, and core/updater.py reads it as the running app's
+current_version when checking for updates.
+"""
+
+__version__ = "1.0.0"
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-<version>.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/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 b023b99..56a5dab 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,13 +11,19 @@ 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
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")
if os.path.exists(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/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..3d022db
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,3 @@
+# Dev/build-only — never installed by end users, not needed to run the app.
+pyinstaller>=6.0
+tufup>=0.10.0
diff --git a/scripts/release/README.md b/scripts/release/README.md
new file mode 100644
index 0000000..54c9086
--- /dev/null
+++ b/scripts/release/README.md
@@ -0,0 +1,61 @@
+# Release process (manual, local signing)
+
+All TUF signing keys stay on your machine, in `./keystore` (gitignored — never
+commit it). Nothing here touches CI or GitHub Secrets.
+
+## One-time setup
+
+```
+pip install -r requirements-dev.txt
+python scripts/release/repo_init.py
+```
+
+Generates `scripts/release/keystore/` (private + public keys for the
+root/targets/snapshot/timestamp TUF roles) and `scripts/release/repository/`
+(initial signed metadata, including `root.json`).
+
+**Back up `keystore/` immediately, somewhere private and durable outside this
+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 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 labui.spec` → produces `dist/LabUI/`.
+3. `python scripts/release/repo_release.py` → creates+signs
+ `scripts/release/repository/targets/labui-<version>.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/`
+ and `scripts/release/repository/targets/` as release assets, **replacing**
+ any same-named files already there.
+ - This tag is fixed on purpose — the app's `metadata_base_url` always
+ points at `https://github.com/<owner>/<repo>/releases/download/updates/`.
+ A normal per-version-tagged release would give every version a
+ different URL, which breaks TUF's requirement that top-level metadata
+ (`timestamp.json`, `snapshot.json`, ...) live at a stable location.
+ - Cut a normal, separately-tagged human-facing release too if you want a
+ changelog/download page — this doesn't have to be the same release.
+5. Test: launch a previous installed version, Settings → Check for Updates,
+ confirm it finds and applies the new version.
+
+## Re-signing without a new release
+
+Metadata expires even if nothing changed (`expiration_days` in
+`repo_init.py`: root 365 days, targets/snapshot/timestamp 90 days). If you
+haven't shipped a release before targets/snapshot/timestamp expire, clients
+will start rejecting the metadata as stale. Re-sign with:
+
+```
+python -m tufup sign snapshot scripts/release/keystore
+python -m tufup sign timestamp scripts/release/keystore
+```
+
+(run from the repo root; adjust the key directory path if you invoke this
+from elsewhere) and re-upload the resulting files to the `updates` release.
+Set a calendar reminder — there is no automation for this on purpose (manual
+signing was a deliberate choice, not an oversight).
diff --git a/scripts/release/repo_init.py b/scripts/release/repo_init.py
new file mode 100644
index 0000000..ccbfe17
--- /dev/null
+++ b/scripts/release/repo_init.py
@@ -0,0 +1,57 @@
+"""
+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 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
+to run it more than once per project.
+
+Keys land in ./keystore — never commit that directory (already gitignored).
+Back it up somewhere safe and private; losing the root key means you can
+never publish another trusted update to existing installs.
+"""
+
+import os
+from pathlib import Path
+
+from tufup.repo import Repository, RolesDict
+
+# Run from this script's own directory — tufup's config file (.tufup-repo-config)
+# and all repo_dir/keys_dir paths are resolved relative to the current working
+# directory, so invocation location must be consistent every time.
+os.chdir(Path(__file__).resolve().parent)
+
+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
+# (see README.md in this directory), so use longer, human-manageable
+# expirations instead. Whichever of these lapses first is the cadence
+# you need to re-sign at, even between actual app releases.
+EXPIRATION_DAYS = RolesDict(root=365, targets=90, snapshot=90, timestamp=90)
+
+
+def main():
+ # Not using app_version_attr here: tufup's setuptools-based attribute
+ # reader resolves dotted paths relative to the current working directory,
+ # which we deliberately pin to this script's own directory below (tufup's
+ # own config-file/relative-path handling expects that) — the two
+ # assumptions conflict. repo_release.py passes the version explicitly
+ # instead, which sidesteps this entirely.
+ repo = Repository(
+ app_name=APP_NAME,
+ repo_dir=Path("repository"),
+ keys_dir=Path("keystore"),
+ expiration_days=EXPIRATION_DAYS,
+ )
+ repo.initialize()
+ repo.save_config()
+ print(f"Initialized tufup repo for '{APP_NAME}' in ./repository, keys in ./keystore")
+ print("Back up ./keystore now, somewhere private and durable. It is gitignored on purpose.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/release/repo_release.py b/scripts/release/repo_release.py
new file mode 100644
index 0000000..d50e458
--- /dev/null
+++ b/scripts/release/repo_release.py
@@ -0,0 +1,64 @@
+"""
+scripts/release/repo_release.py
+
+Run after every `pyinstaller labui.spec` build, from anywhere (paths are
+made absolute below before the working directory changes). Packages the
+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/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
+"updates" GitHub Release.
+"""
+
+import os
+import sys
+from pathlib import Path
+
+_THIS_DIR = Path(__file__).resolve().parent
+_REPO_ROOT = _THIS_DIR.parent.parent
+_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.
+sys.path.insert(0, str(_REPO_ROOT))
+from core.version import __version__ as APP_VERSION
+
+from tufup.repo import Repository
+
+# tufup's config file / relative repo_dir / keys_dir all resolve against cwd
+# (same reasoning as repo_init.py) — pin it here too, for the same reason.
+os.chdir(_THIS_DIR)
+
+
+def main():
+ if not _DIST_DIR.is_dir():
+ raise SystemExit(
+ f"{_DIST_DIR} not found — run `pyinstaller labui.spec` from the "
+ f"repo root first."
+ )
+
+ repo = Repository.from_config()
+
+ changelog = input(f"One-line changelog for v{APP_VERSION} (optional): ").strip()
+ custom_metadata = {"changelog": changelog} if changelog else None
+
+ repo.add_bundle(
+ new_bundle_dir=_DIST_DIR,
+ new_version=APP_VERSION,
+ skip_patch=True, # no binary delta patching for now — full archive only
+ custom_metadata=custom_metadata,
+ )
+ repo.publish_changes(private_key_dirs=[Path("keystore")])
+
+ print(f"Signed and published v{APP_VERSION} to ./repository")
+ print("Next: upload ./repository/metadata/* and ./repository/targets/* "
+ "to the 'updates' GitHub Release — see README.md in this directory.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ui/main_window.py b/ui/main_window.py
index 1ee8b7f..c7e4953 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -121,7 +121,7 @@ _LIGHT_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_lig
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
- self.setWindowTitle("LabDAQ")
+ self.setWindowTitle("LabUI")
self.setMinimumSize(1000, 640)
self.registry = DeviceRegistry()
@@ -133,9 +133,14 @@ class MainWindow(QMainWindow):
self._win_config = None
self._win_settings = 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 = {}
@@ -290,6 +295,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)
@@ -438,27 +447,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"
@@ -575,12 +612,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 15af970..146c162 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 a7b3cb6..19d8025 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 <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 a1d9de1..a45413d 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)
@@ -119,6 +120,23 @@ class SettingsWindow(QWidget):
)
lay.addRow("Developer mode:", self._dev_chk)
+ from core.version import __version__ as _app_version
+ version_lbl = QLabel(f"v{_app_version}")
+ version_lbl.setObjectName("traceSource")
+ lay.addRow("Version:", version_lbl)
+
+ update_row = QHBoxLayout()
+ self._update_btn = QPushButton("Check for Updates")
+ self._update_btn.setObjectName("configButton")
+ self._update_btn.clicked.connect(self._check_for_updates)
+ update_row.addWidget(self._update_btn)
+ update_row.addStretch()
+ lay.addRow("Updates:", update_row)
+ self._update_status_lbl = QLabel("")
+ self._update_status_lbl.setObjectName("traceSource")
+ self._update_status_lbl.setWordWrap(True)
+ lay.addRow("", self._update_status_lbl)
+
rst = QPushButton("Reset to Defaults"); rst.setObjectName("configButton")
rst.clicked.connect(self._reset_to_defaults)
lay.addRow("", rst)
@@ -191,48 +209,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")
@@ -240,33 +267,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):
@@ -285,8 +315,81 @@ 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):
+ from core.updater import is_frozen
+
+ if not is_frozen():
+ self._update_status_lbl.setText("Not available outside the packaged app (dev mode).")
+ return
+
+ from PyQt6.QtWidgets import QApplication
+ from core.updater import apply_update, check_for_update
+
+ self._update_btn.setEnabled(False)
+ self._update_status_lbl.setText("Checking…")
+ QApplication.processEvents()
+ try:
+ new_version = check_for_update()
+ except Exception as e:
+ self._update_status_lbl.setText(f"Check failed: {e}")
+ self._update_btn.setEnabled(True)
+ return
+ self._update_btn.setEnabled(True)
+
+ if not new_version:
+ self._update_status_lbl.setText("Up to date.")
+ return
+
+ self._update_status_lbl.setText(f"Version {new_version} available.")
+ reply = QMessageBox.question(
+ self, "Update Available",
+ f"Version {new_version} is available. Download and install now?\n\n"
+ f"The app will close and relaunch to complete the update.",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ )
+ if reply == QMessageBox.StandardButton.Yes:
+ try:
+ apply_update() # may close/relaunch the process during this call
+ except Exception as e:
+ QMessageBox.critical(self, "Update Failed", str(e))
+
def _reset_to_defaults(self):
reply = QMessageBox.question(
self, "Reset Settings",