summaryrefslogtreecommitdiff
path: root/core
diff options
context:
space:
mode:
Diffstat (limited to 'core')
-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
4 files changed, 150 insertions, 9 deletions
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"