mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
refactor(web): read plugins through a PluginCatalog; only the display runs them (#688)
The web process built its own PluginManager and loaded plugins into itself: store installs and updates loaded or reloaded a web-side copy, and config saves and enable/disable called on_config_change, on_enable and on_disable on it. None of that reached the panel, and /plugins/installed reported runtime state from those copies. - Add PluginCatalog (src/plugin_system/plugin_catalog.py): manifests, directories, display modes, installed version, schema and config reads, with no way to run a plugin. app.py and both blueprints use it; the plugin_manager blueprint attribute is gone. - Remove every lifecycle call from the web routes. Config changes already reach the display through ConfigService (on_config_change) and the enabled-set reconcile. - Health and metrics readers move to api_v3.health_tracker / resource_monitor. /plugins/installed reports loaded/state/error_info as null (the display does not publish them) and enabled by the display's rule. - Store install, update and uninstall answer restart_required when the running display will not pick the change up by itself (display_restart_required). The restart banner follows the flag via window.noteRestartRequired instead of the /config/main URL heuristic; /config/main now sends restart_required: true. - The one remaining in-process import of plugin code (Starlark helper modules, oauth_flow action scripts) goes through _import_plugin_code_in_web_process() until a web-entry contract. - /plugins/installed reports vegas_participation (from #682) from the user's setting or the manifest, with vegas_participation_source; when only the plugin's code decides it, null with source 'runtime', since the web process no longer has plugin instances to ask. - Check & Update All keeps its restart flags when the final list refresh fails, and asks for a restart when an enabled plugin's first request got no answer and the re-sent one found it up to date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ from web_interface.blueprints.api_v3 import (
|
||||
)
|
||||
from src.common.path_safety import safe_path_component
|
||||
from src.plugin_system.base_plugin import (
|
||||
configured_vegas_participation, resolve_vegas_participation,
|
||||
configured_vegas_participation, vegas_participation_value,
|
||||
)
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
@@ -20,18 +20,48 @@ import web_interface.blueprints.api_v3 as _pkg
|
||||
# package is the only patch point that covers every caller.
|
||||
|
||||
|
||||
def _vegas_participation(plugin_id, plugin_config, manifest):
|
||||
"""What Vegas does with a plugin, as far as its files say, and from where.
|
||||
|
||||
The order the display resolves it in (resolve_vegas_participation), up
|
||||
to where that needs the plugin's code: the user's ``vegas_participation`` setting
|
||||
(``'config'``), then the manifest's declared ``vegas_participation``
|
||||
(``'manifest'``). Past those the display asks the plugin itself -- a
|
||||
get_vegas_participation() override or the legacy Vegas hooks -- which the
|
||||
web process never runs, so the answer is ``(None, 'runtime')``: decided
|
||||
at run time, not guessed here. A plugin that overrides
|
||||
get_vegas_participation() can still differ from its manifest.
|
||||
"""
|
||||
configured = configured_vegas_participation(plugin_id, plugin_config)
|
||||
if configured is not None:
|
||||
return configured, 'config'
|
||||
declared = vegas_participation_value(
|
||||
manifest.get('vegas_participation') if isinstance(manifest, dict) else None)
|
||||
if declared is not None:
|
||||
return declared, 'manifest'
|
||||
return None, 'runtime'
|
||||
|
||||
|
||||
@api_v3.route('/plugins/installed', methods=['GET'])
|
||||
def get_installed_plugins():
|
||||
"""Get installed plugins"""
|
||||
if not api_v3.plugin_manager or not api_v3.plugin_store_manager:
|
||||
"""Get installed plugins.
|
||||
|
||||
Metadata comes from the plugin catalog (manifests on disk), ``enabled``
|
||||
from config.json. ``loaded``, ``state`` and ``error_info`` are always
|
||||
null: they would describe the display process's plugin instances, and
|
||||
the display does not publish which plugins it has loaded. What it does
|
||||
publish -- health, metrics, errors -- is served by /plugins/health,
|
||||
/plugins/metrics and /errors.
|
||||
"""
|
||||
if not api_v3.plugin_catalog or not api_v3.plugin_store_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin managers not initialized'}), 500
|
||||
|
||||
# Re-discover plugins to ensure we have the latest list
|
||||
# This handles cases where plugins are added/removed after app startup
|
||||
api_v3.plugin_manager.discover_plugins()
|
||||
api_v3.plugin_catalog.discover_plugins()
|
||||
|
||||
# Get all installed plugin info from the plugin manager
|
||||
all_plugin_info = api_v3.plugin_manager.get_all_plugin_info()
|
||||
# Get all installed plugin info from the catalog
|
||||
all_plugin_info = api_v3.plugin_catalog.get_all_plugin_info()
|
||||
|
||||
# Load config once before the loop (not per-plugin)
|
||||
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||
@@ -45,18 +75,6 @@ def get_installed_plugins():
|
||||
return None
|
||||
|
||||
def _build_plugin_entry_inner(plugin_info, plugin_id):
|
||||
# Capture runtime state (state machine + error context) before the
|
||||
# manifest merge below can shadow the 'state' key. get_all_plugin_info
|
||||
# attaches this via PluginStateManager.get_state_info(); surfacing it
|
||||
# lets the UI show *why* a plugin isn't running instead of just
|
||||
# 'loaded: false'.
|
||||
state_info = plugin_info.get('state')
|
||||
plugin_state = None
|
||||
plugin_error_info = None
|
||||
if isinstance(state_info, dict):
|
||||
plugin_state = state_info.get('state')
|
||||
plugin_error_info = state_info.get('error_info')
|
||||
|
||||
# Re-read manifest from disk to ensure we have the latest metadata.
|
||||
# Through the resolver, not plugins_dir/<id>: a plugin installed as
|
||||
# ledmatrix-<id> otherwise never had its manifest refreshed here.
|
||||
@@ -74,16 +92,13 @@ def get_installed_plugins():
|
||||
except (FileNotFoundError, PermissionError, json.JSONDecodeError) as e:
|
||||
logger.debug("Could not read fresh manifest for %s: %s", plugin_id, e)
|
||||
|
||||
# Enabled status: config is source of truth, fall back to instance
|
||||
enabled = None
|
||||
# Enabled status: config.json, read by the display's rule -- it runs
|
||||
# a plugin only when its section says "enabled": true, so a missing
|
||||
# flag is disabled here too.
|
||||
plugin_config = full_config.get(plugin_id, {})
|
||||
if 'enabled' in plugin_config:
|
||||
enabled = bool(plugin_config['enabled'])
|
||||
|
||||
# Single get_plugin() call shared for both enabled fallback and Vegas mode
|
||||
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
|
||||
if enabled is None:
|
||||
enabled = plugin_instance.enabled if plugin_instance else True
|
||||
if not isinstance(plugin_config, dict):
|
||||
plugin_config = {}
|
||||
enabled = bool(plugin_config.get('enabled', False))
|
||||
|
||||
# Verified + latest published version from registry (no network call)
|
||||
store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
|
||||
@@ -113,32 +128,16 @@ def get_installed_plugins():
|
||||
if store_info and not last_commit_message:
|
||||
last_commit_message = store_info.get('last_commit_message')
|
||||
|
||||
# Vegas mode from instance, overridden by explicit config value
|
||||
vegas_mode = None
|
||||
# Vegas mode as configured. What a plugin's code would choose on its
|
||||
# own is only known to the display, which runs it.
|
||||
vegas_mode = plugin_config.get('vegas_mode')
|
||||
vegas_content_type = None
|
||||
if plugin_instance:
|
||||
try:
|
||||
if hasattr(plugin_instance, 'get_vegas_display_mode'):
|
||||
mode = plugin_instance.get_vegas_display_mode()
|
||||
vegas_mode = mode.value if hasattr(mode, 'value') else str(mode)
|
||||
except (AttributeError, TypeError, ValueError) as e:
|
||||
logger.debug("[%s] Failed to get vegas_display_mode: %s", plugin_id, e)
|
||||
try:
|
||||
if hasattr(plugin_instance, 'get_vegas_content_type'):
|
||||
vegas_content_type = plugin_instance.get_vegas_content_type()
|
||||
except (AttributeError, TypeError, ValueError) as e:
|
||||
logger.debug("[%s] Failed to get vegas_content_type: %s", plugin_id, e)
|
||||
|
||||
if 'vegas_mode' in plugin_config:
|
||||
vegas_mode = plugin_config['vegas_mode']
|
||||
|
||||
# What Vegas actually does with the plugin: 'scroll', 'pause' or
|
||||
# 'exclude'. The same resolution the ticker uses; without a loaded
|
||||
# instance only the user's own setting is known.
|
||||
if plugin_instance is not None:
|
||||
vegas_participation = resolve_vegas_participation(plugin_instance, plugin_id)
|
||||
else:
|
||||
vegas_participation = configured_vegas_participation(plugin_id, plugin_config)
|
||||
# What Vegas does with it: 'scroll', 'pause' or 'exclude', or None
|
||||
# when only the plugin's code (run by the display) decides. The Vegas
|
||||
# order list badges a None as its configured vegas_mode, else Scroll.
|
||||
vegas_participation, vegas_participation_source = _vegas_participation(
|
||||
plugin_id, plugin_config, plugin_info)
|
||||
|
||||
return {
|
||||
'id': plugin_id,
|
||||
@@ -155,9 +154,10 @@ def get_installed_plugins():
|
||||
'icon': plugin_info.get('icon') if isinstance(plugin_info.get('icon'), str) else None,
|
||||
'enabled': enabled,
|
||||
'verified': verified,
|
||||
'loaded': plugin_info.get('loaded', False),
|
||||
'state': plugin_state,
|
||||
'error_info': plugin_error_info,
|
||||
# Not published by the display process; see the docstring.
|
||||
'loaded': None,
|
||||
'state': None,
|
||||
'error_info': None,
|
||||
'last_updated': last_updated,
|
||||
'last_commit': last_commit,
|
||||
'last_commit_message': last_commit_message,
|
||||
@@ -166,6 +166,7 @@ def get_installed_plugins():
|
||||
'vegas_mode': vegas_mode,
|
||||
'vegas_content_type': vegas_content_type,
|
||||
'vegas_participation': vegas_participation,
|
||||
'vegas_participation_source': vegas_participation_source,
|
||||
}
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -183,7 +184,7 @@ def toggle_plugin():
|
||||
plugin_id = None
|
||||
enabled = None
|
||||
try:
|
||||
if not api_v3.plugin_manager or not api_v3.config_manager:
|
||||
if not api_v3.plugin_catalog or not api_v3.config_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin or config manager not initialized'}), 500
|
||||
|
||||
# Support both JSON and form data (for HTMX submissions)
|
||||
@@ -221,7 +222,7 @@ def toggle_plugin():
|
||||
current_enabled = config.get(plugin_id, {}).get('enabled', False)
|
||||
enabled = not current_enabled
|
||||
|
||||
# A Starlark app is not a plugin in plugin_manager's sense -- it is an
|
||||
# A Starlark app is not a plugin in the catalog's sense -- it is an
|
||||
# entry in starlark-apps' own manifest -- so its enable/disable is
|
||||
# handled here rather than falling through to the check below, which
|
||||
# would answer "Plugin not found".
|
||||
@@ -258,21 +259,9 @@ def toggle_plugin():
|
||||
status="success"
|
||||
)
|
||||
|
||||
# If plugin is loaded, also call its lifecycle methods
|
||||
# Wrap in try/except to prevent lifecycle errors from failing the toggle
|
||||
plugin = api_v3.plugin_manager.get_plugin(plugin_id)
|
||||
if plugin:
|
||||
try:
|
||||
if enabled:
|
||||
if hasattr(plugin, 'on_enable'):
|
||||
plugin.on_enable()
|
||||
else:
|
||||
if hasattr(plugin, 'on_disable'):
|
||||
plugin.on_disable()
|
||||
except Exception as lifecycle_error:
|
||||
# Log the error but don't fail the toggle - config is already saved
|
||||
logger.warning("Lifecycle method error for %s: %s", plugin_id, lifecycle_error, exc_info=True)
|
||||
|
||||
# No lifecycle hooks here: the display's config watcher sees the
|
||||
# enabled flag change and loads or unloads the plugin itself
|
||||
# (DisplayController._reconcile_enabled_plugins).
|
||||
return success_response(
|
||||
message=f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully"
|
||||
)
|
||||
@@ -511,16 +500,11 @@ sys.exit(proc.returncode)
|
||||
# Step 1: Get initial data (like auth URL)
|
||||
# For OAuth flows, we might need to import the script as a module
|
||||
if action_def.get('oauth_flow'):
|
||||
# Import script as module to get auth URL
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("plugin_action", script_file)
|
||||
action_module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["plugin_action"] = action_module
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(action_module)
|
||||
# Plugin code in the web process: see the
|
||||
# function for why, and what replaces it.
|
||||
action_module = _pkg._import_plugin_code_in_web_process(
|
||||
"plugin_action", script_file, reuse=False)
|
||||
|
||||
# Try to get auth URL using common patterns
|
||||
auth_url = None
|
||||
|
||||
Reference in New Issue
Block a user