Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes follow Keep a Changelog and Semantic Versioning.

## [Unreleased]

### Added

- Check loaded plugins for validated community updates at startup and hourly, show
version-aware status, and stage selected updates for the next application start.
- Give each plugin a persistent, distinct symbol and show its name when hovering over
contributed menus, functions, solvers, workflows and plugin-manager entries.

### Fixed

- Ruby monitor 0.4.0 defaults to 296 K without confirmation, reports pressure with
Expand Down
3 changes: 2 additions & 1 deletion custom_plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ pass on Linux, Windows and macOS. Failed checks leave the previous download in p
unmerged pull requests are never offered through these links.

Extract the bundle, then open **File > Plugin manager**, select the chosen plugin's
folder, review and load it. Its additions carry the ◆ marker; built-in actions are
folder, review and load it. Its additions carry a plugin-specific symbol; hover over it to see the plugin name.
Built-in actions are
not replaced. Plugins remain installed across restarts and can be disabled/removed
in the manager. No plugin is downloaded, installed or trusted automatically.

Expand Down
29 changes: 25 additions & 4 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
# Creating and managing CurveMole plugins

Plugins add features to CurveMole. They do not replace built-in commands, exporters,
solvers or functions through the supported API. Every contribution has a diamond
symbol (◆) in its menu or selector and identifies its providing plugin in the tooltip.
solvers or functions through the supported API. Every contribution has its plugin's own symbol (for example ◆, ● or ▲) in its
menu or selector and identifies its providing plugin in the tooltip.
Executable Python plugins are different from safe custom-function/formula JSON files.

## Updates inside CurveMole

CurveMole checks at startup and hourly for updates to **loaded plugins only**, using
its validated Community Plugins catalog. The **Plugins** status badge follows the
application's colours: green when current, yellow for bug fixes, red for feature or
major updates. An available version is announced once. Open the badge or
**File > Plugin Manager > Plugin updates…** to check again and choose **Update selected**.
**Help > Check for updates** checks the application and loaded plugins.

Updating downloads and trusts the selected versions. CurveMole verifies the archive
against the catalog and checks plugin identity and API compatibility before switching
the saved installation. Save your project and reopen CurveMole to activate updates.
Current panels, fits and folder monitoring continue with the running version until
then. Original plugin folders and project settings are preserved; the updated copy,
including its own README/manual, is kept in CurveMole's plugin storage.

Unloaded or disabled plugins are not checked or installed. Plugins absent from the
community catalog, package entry points and nonstandard versions are reported as
having no automatic update source. Incompatible updates require updating CurveMole
first. Failed or interrupted downloads leave the previous installation in place.

## Install, enable, disable and remove

1. Keep the plugin's `.curvemole-plugin.json` manifest and its Python module together
Expand Down Expand Up @@ -92,7 +113,7 @@ def register(api):
description="Active spectrum, comma-separated x/y with header")
```

This adds **File > Exporters > ◆ My CSV export**. CurveMole asks for a destination;
This adds **File > Plugins: Exporters > ◆ My CSV export**. CurveMole asks for a destination;
the built-in export commands remain available. The callback owns the external file
write; use a temporary file followed by atomic replacement if partial output would
be a problem. File writes cannot be undone by CurveMole's Undo.
Expand Down Expand Up @@ -203,7 +224,7 @@ def register(api):
))
```

Use a namespaced function identifier. Functions receive the diamond symbol in their
Use a namespaced function identifier. Functions receive their plugin's symbol in their
display names. Registering an existing identifier or passing `replace=True` fails,
with no changes to built-ins. Custom function JSON import/export remains separate.

Expand Down
9 changes: 6 additions & 3 deletions docs/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,13 @@ choose an entry to reopen it, or clear the list from that menu.

Open **File > Plugin Manager**, choose the folder containing a plugin manifest and
Python module, scan, review the source and explicitly trust it. Loaded plugins add
entries marked with a diamond symbol; built-in commands stay available. Enabled
entries marked with a distinct symbol for each plugin; hover over a marked entry to
see the plugin name. Built-in commands stay available. Enabled
plugins load again on restart. Use **Disable** or **Remove** in the manager to stop
loading them. After an abnormal exit, automatic plugin loading is disabled so you
can recover safely. See [the plugin guide](plugins.md) for installation details,
loading them. CurveMole checks loaded plugins at startup and hourly against the
validated community catalog. Open the **Plugins** badge or **Plugin updates...** in
the manager to install selected updates; reopen CurveMole to activate them. After
an abnormal exit, automatic plugin loading is disabled so you can recover safely. See [the plugin guide](plugins.md) for installation details,
security limitations and complete examples for writing an exporter or fit algorithm.


Expand Down
14 changes: 10 additions & 4 deletions src/curvemole/core/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class Contribution:
callback: Callable[..., Any]
description: str = ""
auto_show: bool = False
symbol: str = "◆"
plugin_name: str = ""


class ExtensionRegistry:
Expand Down Expand Up @@ -54,8 +56,10 @@ def register(self, definition: Any, *, replace: bool = False) -> None:
def evaluate(*args: Any, **kwargs: Any) -> Any:
return self._manager.invoke(self.identifier, original, *args, **kwargs)
marked = dataclass_replace(
definition, display_name=f"◆ {definition.display_name}", evaluator=evaluate,
custom_metadata={**definition.custom_metadata, "plugin_owner": self.identifier})
definition, display_name=f"{self._manager.symbol(self.identifier)} {definition.display_name}", evaluator=evaluate,
custom_metadata={**definition.custom_metadata, "plugin_owner": self.identifier,
"plugin_symbol": self._manager.symbol(self.identifier),
"plugin_name": self._manager.plugin_name(self.identifier)})
self._manager.registry.register(marked)
self._manager.function_owners[definition.identifier] = self.identifier

Expand Down Expand Up @@ -83,6 +87,8 @@ def checked(*args: Any, **kwargs: Any) -> Any:

def guarded(*args: Any, **kwargs: Any) -> Any:
return self._manager.invoke(self.identifier, checked, *args, **kwargs)
extensions.entries[key] = Contribution(self.identifier, key, f"◆ {label}", kind,
guarded, description, auto_show)
extensions.entries[key] = Contribution(
self.identifier, key, f"{self._manager.symbol(self.identifier)} {label}", kind,
guarded, description, auto_show, self._manager.symbol(self.identifier),
self._manager.plugin_name(self.identifier))
return key
20 changes: 20 additions & 0 deletions src/curvemole/core/plugin_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Consistent plugin provenance labels, independent of Qt."""
SYMBOLS = ("◆", "●", "▲", "■", "★", "⬟", "▼", "◉", "✚", "◈", "◐", "✦")


def provenance(symbol: str, name: str, identifier: str) -> str:
return f"{symbol} This feature belongs to plugin {name} ({identifier})."


def function_tooltip(definition) -> str:
metadata = definition.custom_metadata
owner = metadata.get("plugin_owner")
if not owner:
return definition.description
return provenance(metadata.get("plugin_symbol", "◆"), metadata.get("plugin_name", owner), owner) + (
"\n" + definition.description if definition.description else "")


def contribution_tooltip(entry) -> str:
return provenance(entry.symbol, entry.plugin_name or entry.owner, entry.owner) + (
"\n" + entry.description if entry.description else "")
176 changes: 176 additions & 0 deletions src/curvemole/core/plugin_updates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""Validated community plugin updates, staged without executing downloaded code."""
from __future__ import annotations

import copy
import hashlib
import io
import json
import re
import shutil
import stat
import tempfile
import zipfile
from dataclasses import asdict, dataclass
from pathlib import Path, PurePosixPath
from typing import Any

from curvemole.core.errors import CurveMoleError
from curvemole.core.plugins import PluginCandidate, PluginManager, PluginMetadata
from curvemole.version import PLUGIN_API_VERSION

DOWNLOAD_ROOT = "https://github.com/SebRoLENS/curvemole/releases/download/community-plugins-latest"
CATALOG_URL = DOWNLOAD_ROOT + "/catalog.json"
MAX_ARCHIVE_BYTES = 20 * 1024 * 1024
MAX_EXTRACTED_BYTES = 80 * 1024 * 1024


def version(value: str) -> tuple[int, ...] | None:
if not re.fullmatch(r"\d+\.\d+\.\d+", value):
return None
return tuple(map(int, value.split(".")))


@dataclass(frozen=True)
class PluginUpdate:
identifier: str
name: str
current: str
latest: str
folder: str
sha256: str
api: str
capabilities: tuple[str, ...]

@property
def url(self) -> str:
return f"{DOWNLOAD_ROOT}/{self.folder}.zip"

@property
def compatible(self) -> bool:
return self.api == PLUGIN_API_VERSION


def available_updates(manager: PluginManager, payload: Any) -> tuple[list[PluginUpdate], list[str]]:
"""Only running local plugins are eligible; never install catalog additions."""
if not isinstance(payload, dict) or not isinstance(payload.get("plugins"), list):
raise CurveMoleError("Invalid community plugin catalog.")
catalog = {}
for item in payload["plugins"]:
if not isinstance(item, dict) or not isinstance(item.get("identifier"), str):
raise CurveMoleError("Invalid community plugin catalog entry.")
if item["identifier"] in catalog:
raise CurveMoleError("Duplicate plugin identifier in catalog.")
catalog[item["identifier"]] = item
updates, unsupported = [], []
for identifier, metadata in manager.loaded.items():
if identifier in manager.errors:
continue
installed = manager.installed.get(identifier, {})
if not installed.get("enabled"):
continue
item = catalog.get(identifier)
current = version(metadata.version)
if item is None or installed.get("kind") != "local" or current is None:
unsupported.append(identifier)
continue
latest = version(str(item.get("version", "")))
folder, digest = item.get("folder", ""), item.get("sha256", "")
if (latest is None or not isinstance(folder, str)
or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]*", folder)
or not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{64}", digest)
or not isinstance(item.get("capabilities"), list)):
raise CurveMoleError(f"Invalid update metadata for {identifier}.")
pending = version(str(installed.get("metadata", {}).get("version", "")))
if latest <= max(current, pending or current):
continue
updates.append(PluginUpdate(identifier, str(item.get("name", identifier)), metadata.version,
item["version"], folder, digest, str(item.get("api_compatibility", "")),
tuple(item["capabilities"])))
return updates, unsupported


def _unpack(archive: bytes, root: Path, update: PluginUpdate) -> Path:
if len(archive) > MAX_ARCHIVE_BYTES:
raise CurveMoleError("Plugin download exceeds the size limit.")
with zipfile.ZipFile(io.BytesIO(archive)) as bundle:
members = bundle.infolist()
if len(members) > 2000 or sum(item.file_size for item in members) > MAX_EXTRACTED_BYTES:
raise CurveMoleError("Plugin archive exceeds the extraction limit.")
seen = set()
for member in members:
path = PurePosixPath(member.filename)
mode = member.external_attr >> 16
if (not path.parts or path.is_absolute() or path.parts[0] != update.folder
or any(part in (".", "..") or ":" in part for part in path.parts)
or "\\" in member.filename or stat.S_ISLNK(mode)
or path.as_posix().casefold() in seen):
raise CurveMoleError("Unsafe or duplicate path in plugin archive.")
seen.add(path.as_posix().casefold())
destination = root.joinpath(*path.parts)
if member.is_dir():
destination.mkdir(parents=True, exist_ok=True)
else:
destination.parent.mkdir(parents=True, exist_ok=True)
with bundle.open(member) as source, destination.open("wb") as output:
shutil.copyfileobj(source, output)
folder = root / update.folder
digest = hashlib.sha256()
for path in sorted(folder.rglob("*"), key=lambda item: item.relative_to(folder).as_posix()):
if path.is_file():
digest.update(path.relative_to(folder).as_posix().encode())
digest.update(b"\0")
digest.update(path.read_bytes())
if digest.hexdigest() != update.sha256:
raise CurveMoleError("Plugin checksum mismatch. Check for updates again and retry.")
return folder


def stage_update(manager: PluginManager, update: PluginUpdate, archive: bytes) -> None:
"""Atomically switch the next-startup record; running code and source stay intact."""
current = manager.loaded.get(update.identifier)
record = manager.installed.get(update.identifier)
if (current is None or current.version != update.current or not record
or not record.get("enabled") or update.identifier in manager.errors
or record.get("kind") != "local"):
raise CurveMoleError("Plugin is no longer loaded; check for updates again.")
if not update.compatible:
raise CurveMoleError("Update CurveMole first: this plugin requires a different plugin API.")
installed_version = version(str(record["metadata"]["version"]))
if version(update.latest) is None or version(update.latest) <= (installed_version or ()):
raise CurveMoleError("This plugin version is already installed or newer.")
if manager.storage is None:
raise CurveMoleError("Persistent plugin storage is unavailable.")
staging = manager.storage / "updates"
staging.mkdir(parents=True, exist_ok=True)
temporary = Path(tempfile.mkdtemp(prefix="plugin-", dir=staging))
original = copy.deepcopy(record)
committed = False
try:
folder = _unpack(archive, temporary, update)
manifests = list(folder.glob("*.curvemole-plugin.json"))
if len(manifests) != 1:
raise CurveMoleError("Update must contain exactly one plugin manifest.")
manifest = manifests[0]
metadata = PluginMetadata.from_mapping(json.loads(manifest.read_text(encoding="utf-8")),
source=str(manifest))
if (metadata.identifier != update.identifier or metadata.version != update.latest
or metadata.api_compatibility != update.api
or metadata.capabilities != update.capabilities
or not re.fullmatch(r"[a-zA-Z0-9_]+\.py", metadata.module or "")
or not (folder / metadata.module).is_file()):
raise CurveMoleError("Plugin manifest does not match the validated catalog.")
candidate = PluginCandidate(metadata, str(manifest), "local")
manager.installed[update.identifier] = {
"metadata": asdict(metadata), "reference": str(manifest), "kind": "local",
"fingerprint": manager._fingerprint(candidate), "enabled": True,
"symbol": manager.symbol(update.identifier),
}
try:
manager._save()
except Exception:
manager.installed[update.identifier] = original
raise
committed = True
finally:
if not committed:
shutil.rmtree(temporary, ignore_errors=True)
28 changes: 28 additions & 0 deletions src/curvemole/core/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class PluginMetadata:
capabilities: tuple[str, ...]
source: str
module: str | None = None
name: str = ""

@classmethod
def from_mapping(cls, value: Mapping[str, Any], *, source: str) -> PluginMetadata:
Expand All @@ -51,6 +52,7 @@ def from_mapping(cls, value: Mapping[str, Any], *, source: str) -> PluginMetadat
licence=str(value["licence"]),
capabilities=tuple(str(item) for item in value["capabilities"]),
source=source,
name=str(value.get("name", "")),
module=str(value["module"]) if value.get("module") else None,
)

Expand All @@ -77,6 +79,8 @@ def __init__(
self.storage = Path(storage) if storage is not None else None
self.installed: dict[str, dict[str, Any]] = {}
self.errors: dict[str, str] = {}
self.symbols: dict[str, str] = {}
self.names: dict[str, str] = {}
self.session_path: Path | None = None
if self.storage:
self.storage.mkdir(parents=True, exist_ok=True)
Expand All @@ -89,6 +93,28 @@ def __init__(
except (OSError, ValueError):
self.installed = {}

def symbol(self, identifier: str) -> str:
from curvemole.core.plugin_identity import SYMBOLS
if identifier not in self.symbols:
used = set(self.symbols.values()) | {
str(record.get("symbol", "")) for record in self.installed.values()}
saved = self.installed.get(identifier, {}).get("symbol")
if saved and saved not in self.symbols.values():
self.symbols[identifier] = saved
else:
index = 0
while True:
candidate = SYMBOLS[index] if index < len(SYMBOLS) else f"◆{index + 1}"
if candidate not in used:
self.symbols[identifier] = candidate
break
index += 1
return self.symbols[identifier]

def plugin_name(self, identifier: str) -> str:
return self.names.get(identifier) or self.installed.get(identifier, {}).get(
"metadata", {}).get("name") or identifier

def _save(self) -> None:
if self.storage:
destination = self.storage / "installed.json"
Expand Down Expand Up @@ -238,6 +264,7 @@ def load(self, candidate: PluginCandidate, *, trust: bool = False) -> PluginMeta
from curvemole.core.extensions import PluginAPI
fingerprint = self._fingerprint(candidate)
self.errors.pop(metadata.identifier, None)
self.names[metadata.identifier] = metadata.name or metadata.identifier
try:
if candidate.kind == "local":
loaded = self._load_local(candidate)
Expand All @@ -262,6 +289,7 @@ def load(self, candidate: PluginCandidate, *, trust: bool = False) -> PluginMeta
"metadata": asdict(metadata), "reference": str(Path(candidate.reference).resolve())
if candidate.kind == "local" else candidate.reference,
"kind": candidate.kind, "fingerprint": fingerprint, "enabled": True,
"symbol": self.symbol(metadata.identifier),
}
self._save()
return metadata
Expand Down
Loading
Loading