mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
* feat(element-style): universal per-element style resolver One shared implementation of the three things every customizable plugin re-invented: a font loader, the customization.layout x/y-offset reader, and the "did the user actually override this font?" check. The override check is the load-bearing piece: the web UI's save flow writes full schema defaults into config.json on every save, and the plugin manager merges defaults again before instantiation, so key presence never means user intent. The resolver compares against the plugin's own schema defaults (via schema_manager), degrading to caller-supplied classic defaults when unavailable. This retires the hand-maintained _CLASSIC_FONT_DEFAULTS dicts that shipped broken twice. The loader is a superset of the four per-plugin variants: alias resolution (baseball), truetype for TTF/OTF/BDF (FreeType loads BDF at native size), .pil sidecar fallback for BDF (football), fallback font, PIL default — never raises. Also introduces the text_color convention ([r,g,b], absent = keep the plugin's hardcoded color). BasePlugin gains a lazy style_resolver property (invalidated on config change) and element_style() sugar; standalone helpers receive the resolver from their owning plugin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * feat(element-style): x-style-elements schema expansion + color provenance Plugins can now declare styleable display elements once, compactly, on their customization schema ("x-style-elements") instead of hand-copying the ~50-line font/font_size/text_color/offset property blocks (currently duplicated 52x across the plugin monorepo). SchemaManager.load_schema expands declarations before caching, so the config form, save path, validation, and defaults generation all see the same shape; the single expansion implementation lives in src.element_style and is also applied by defaults_from_schema_file, keeping the web UI's view and a plugin's raw-schema-file view of the defaults provably identical (parity test). Generated blocks use only widgets the config form already renders (font-selector, color-picker, number inputs) and update x-propertyOrder when present (the template only renders listed keys). Expansion is idempotent, never mutates its input or the cached/on-disk schema, and a hand-written block for the same element always wins. Color gets the same provenance rule as fonts: the web form always posts the RGB inputs, so a saved config carries the schema-default color whether or not the user touched it — the resolver now only honors a color that DIFFERS from the schema default, and keeps the plugin's classic (possibly state-dependent, e.g. gold-on-touchdown) color otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * feat(web): live plugin preview on the config page Adds POST /api/v3/plugins/preview: renders a plugin headlessly with the CANDIDATE (unsaved) config and returns a PNG — so users can see exactly what the panel will show before saving, at their real panel size (from display.hardware) or a chosen test size. Two extractions make it drift-proof rather than parallel-implemented: - dev_server's _render_once moves to src/plugin_system/testing/render_service.py (pure PIL via VisualTestDisplayManager, install_deps=False always — safe in the web process, which never touches display hardware); the dev server now wraps it. - save_plugin_config's ~350-line form->config conversion is extracted verbatim as parse_plugin_config_form and shared by the preview endpoint, so preview and save can never interpret the form differently. update() is skipped by default (no network on the request thread); plugins with a test/harness.json get their mock-data fixture primed instead, and ?skip_update=0 opts into a live update. The config page gains a Live Preview panel (HTMX hx-include of the existing form, size selector, pixelated img fragment) — works for every plugin with zero per-plugin code, including the x-style-elements font/size/color fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(web): expand x-style-elements in the config-page form render too pages_v3's plugin-config partial loads config_schema.json directly from disk rather than through SchemaManager.load_schema, so declared style elements expanded everywhere EXCEPT the form the user actually sees. Found live on the devpi: the API served the expanded schema and the save path validated against it, but the config page rendered no font/color/offset fields. Apply the same (idempotent) expansion here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(web): preview size selector — htmx caches hx-post, use hx-vals Found live on the devpi: selecting a preview size did nothing because htmx snapshots the request path when it processes the button, so the size dropdown's onchange mutation of hx-post never took effect. The size now travels as a __preview_size=WxH form field attached via hx-vals (evaluated at request time); the endpoint honors it (query args still win for API callers) and strips it before form parsing so it can't leak into the candidate config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(web): allowlist plugin_id in the preview endpoint's dir lookup Same defense as the dev server's find_plugin_dir (and pages_v3's existing pattern): plugin_id arrives in request input and is used to build filesystem paths — reject anything outside ^[a-zA-Z0-9_-]{1,64}$ before touching the filesystem. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix: harden preview + resolver edges found in self-review - extract_schema_defaults now matches SchemaManager's array handling ([] for arrays without defaults, [item] for item-level defaults) — the documented parity guarantee was false for array-typed properties; parity test extended to cover them. - render_plugin_once calls plugin cleanup() in a finally (image captured first — cleanup may clear the canvas): preview instances could leak sessions/threads per request in the long-running web process. - Preview JSON path deep-merges the candidate onto the saved config, matching the form path — a shallow update() silently dropped saved sibling values in any nested section the candidate touched. - Preview render is bounded (15s, 504 on timeout, daemonized worker): a hanging plugin display() no longer pins a web worker forever. - ElementStyleResolver.is_for(config): public staleness check for callers that cache a resolver, instead of poking _config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(render-service): adopt the dev server's exception-detail policy Port c6962701's convention into the shared render service (which supersedes the inline _render_once it patched): full tracebacks to the server log via exc_info, only the exception class name to the client. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
383 lines
14 KiB
Python
383 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
LEDMatrix Dev Preview Server
|
|
|
|
A standalone lightweight Flask app for rapid plugin development.
|
|
Pick a plugin, tweak its config, and instantly see the rendered display.
|
|
|
|
Usage:
|
|
python scripts/dev_server.py
|
|
python scripts/dev_server.py --port 5001
|
|
python scripts/dev_server.py --extra-dir /path/to/custom-plugin
|
|
|
|
Opens at http://localhost:5001
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
import re
|
|
import time
|
|
import argparse
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
# Add project root to path
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
# Prevent hardware imports
|
|
os.environ['EMULATOR'] = 'true'
|
|
|
|
from flask import Flask, render_template, request, jsonify
|
|
|
|
app = Flask(__name__, template_folder=str(Path(__file__).parent / 'templates'))
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Will be set from CLI args
|
|
_extra_dirs: List[str] = []
|
|
|
|
# Render endpoint resource guards
|
|
MAX_WIDTH = 512
|
|
MAX_HEIGHT = 512
|
|
MIN_WIDTH = 1
|
|
MIN_HEIGHT = 1
|
|
|
|
# plugin_id arrives in request input and is used to build filesystem paths —
|
|
# allowlist it (same pattern the web UI's pages_v3 uses)
|
|
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Plugin discovery
|
|
# --------------------------------------------------------------------------
|
|
|
|
def get_search_dirs() -> List[Path]:
|
|
"""Get all directories to search for plugins."""
|
|
dirs = [
|
|
PROJECT_ROOT / 'plugins',
|
|
PROJECT_ROOT / 'plugin-repos',
|
|
]
|
|
for d in _extra_dirs:
|
|
dirs.append(Path(d))
|
|
return dirs
|
|
|
|
|
|
def discover_plugins() -> List[Dict[str, Any]]:
|
|
"""Discover all available plugins across search directories."""
|
|
plugins: List[Dict[str, Any]] = []
|
|
seen_ids: set = set()
|
|
|
|
for search_dir in get_search_dirs():
|
|
if not search_dir.exists():
|
|
logger.debug("[Dev Server] Search dir missing, skipping: %s", search_dir)
|
|
continue
|
|
for item in sorted(search_dir.iterdir()):
|
|
if item.name.startswith('.') or not item.is_dir():
|
|
logger.debug("[Dev Server] Skipping non-plugin entry: %s", item)
|
|
continue
|
|
manifest_path = item / 'manifest.json'
|
|
if not manifest_path.exists():
|
|
logger.debug("[Dev Server] No manifest.json in %s, skipping", item)
|
|
continue
|
|
try:
|
|
with open(manifest_path, 'r') as f:
|
|
manifest: Dict[str, Any] = json.load(f)
|
|
plugin_id: str = manifest.get('id', item.name)
|
|
if plugin_id in seen_ids:
|
|
logger.debug("[Dev Server] Duplicate plugin_id '%s' at %s, skipping", plugin_id, item)
|
|
continue
|
|
seen_ids.add(plugin_id)
|
|
logger.debug("[Dev Server] Discovered plugin id=%s name=%s", plugin_id, manifest.get('name', plugin_id))
|
|
plugins.append({
|
|
'id': plugin_id,
|
|
'name': manifest.get('name', plugin_id),
|
|
'description': manifest.get('description', ''),
|
|
'author': manifest.get('author', ''),
|
|
'version': manifest.get('version', ''),
|
|
'source_dir': str(search_dir),
|
|
'plugin_dir': str(item),
|
|
})
|
|
except json.JSONDecodeError as e:
|
|
logger.warning("[Dev Server] JSON decode error in %s: %s", manifest_path, e)
|
|
continue
|
|
except OSError as e:
|
|
logger.warning("[Dev Server] OS error reading %s: %s", manifest_path, e)
|
|
continue
|
|
|
|
return plugins
|
|
|
|
|
|
def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
|
"""Find a plugin directory by ID.
|
|
|
|
plugin_id comes from request input: it must pass an allowlist match,
|
|
and the resulting directory is normalized and required to live inside
|
|
one of the plugin search dirs, so a crafted id can never name a path
|
|
outside them.
|
|
"""
|
|
if not isinstance(plugin_id, str) or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
|
return None
|
|
from src.plugin_system.plugin_loader import PluginLoader
|
|
loader = PluginLoader()
|
|
for search_dir in get_search_dirs():
|
|
if not search_dir.exists():
|
|
continue
|
|
result = loader.find_plugin_directory(plugin_id, search_dir)
|
|
if not result:
|
|
continue
|
|
# Normalize WITHOUT following symlinks (dev plugins are often
|
|
# symlinked into plugins/) and require lexical containment in the
|
|
# search dir, so no id can ever name a path outside it.
|
|
result_abs = os.path.abspath(str(result))
|
|
root_abs = os.path.abspath(str(search_dir))
|
|
if os.path.commonpath([result_abs, root_abs]) == root_abs:
|
|
return Path(result_abs)
|
|
return None
|
|
|
|
|
|
def load_config_defaults(plugin_dir: 'str | Path') -> Dict[str, Any]:
|
|
"""Extract default values from config_schema.json."""
|
|
schema_path = Path(plugin_dir) / 'config_schema.json'
|
|
if not schema_path.exists():
|
|
return {}
|
|
with open(schema_path, 'r') as f:
|
|
schema = json.load(f)
|
|
defaults: Dict[str, Any] = {}
|
|
for key, prop in schema.get('properties', {}).items():
|
|
if 'default' in prop:
|
|
defaults[key] = prop['default']
|
|
return defaults
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Routes
|
|
# --------------------------------------------------------------------------
|
|
|
|
@app.route('/')
|
|
def index():
|
|
"""Serve the dev preview page."""
|
|
return render_template('dev_preview.html')
|
|
|
|
|
|
@app.route('/api/plugins')
|
|
def api_plugins():
|
|
"""List all available plugins."""
|
|
return jsonify({'plugins': discover_plugins()})
|
|
|
|
|
|
@app.route('/api/plugins/<plugin_id>/schema')
|
|
def api_plugin_schema(plugin_id):
|
|
"""Get a plugin's config_schema.json."""
|
|
plugin_dir = find_plugin_dir(plugin_id)
|
|
if not plugin_dir:
|
|
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
|
|
|
schema_path = plugin_dir / 'config_schema.json'
|
|
if not schema_path.exists():
|
|
return jsonify({'schema': {'type': 'object', 'properties': {}}})
|
|
|
|
with open(schema_path, 'r') as f:
|
|
schema = json.load(f)
|
|
return jsonify({'schema': schema})
|
|
|
|
|
|
@app.route('/api/plugins/<plugin_id>/defaults')
|
|
def api_plugin_defaults(plugin_id):
|
|
"""Get default config values from the schema."""
|
|
plugin_dir = find_plugin_dir(plugin_id)
|
|
if not plugin_dir:
|
|
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
|
|
|
defaults = load_config_defaults(plugin_dir)
|
|
defaults['enabled'] = True
|
|
return jsonify({'defaults': defaults})
|
|
|
|
|
|
def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
|
|
skip_update):
|
|
"""Render one plugin at one size. Returns the /api/render response dict.
|
|
|
|
Thin wrapper over the shared render service (also used by the web UI's
|
|
config-page preview); a fresh plugin instance per call, mirroring the
|
|
safety harness, so sizes never share state.
|
|
"""
|
|
from src.plugin_system.testing.render_service import render_plugin_once
|
|
|
|
return render_plugin_once(
|
|
plugin_id, plugin_dir, manifest=manifest, config=config,
|
|
mock_data=mock_data, width=width, height=height,
|
|
skip_update=skip_update)
|
|
|
|
|
|
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
|
|
"""Re-derive a plugin directory from the search dirs' own listings.
|
|
|
|
Path-injection barrier: unlike ``Path.iterdir()`` (which CodeQL doesn't
|
|
recognize as a taint-clearing enumeration), ``os.scandir()`` is. The
|
|
returned Path is built from a trusted root plus a name the filesystem
|
|
itself produced under that root via scandir — request-derived strings
|
|
never enter its construction — so a crafted plugin id can never make
|
|
downstream file access leave the plugin search dirs. Comparison is by
|
|
name, deliberately without symlink resolution (dev plugins are
|
|
commonly symlinked into plugins/).
|
|
"""
|
|
wanted_name = Path(os.path.normpath(str(plugin_dir))).name
|
|
for search_dir in get_search_dirs():
|
|
search_dir_str = str(search_dir)
|
|
try:
|
|
with os.scandir(search_dir_str) as entries:
|
|
for entry in entries:
|
|
if entry.name == wanted_name and entry.is_dir():
|
|
return Path(search_dir_str) / entry.name
|
|
except OSError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _parse_render_request(data):
|
|
"""Shared /api/render* request prep. Returns (plugin_dir, manifest, config,
|
|
mock_data, skip_update) or raises ValueError with a client message."""
|
|
plugin_id = data['plugin_id']
|
|
candidate_dir = find_plugin_dir(plugin_id)
|
|
# Never reuse `candidate_dir` past this point: it's built from
|
|
# request-derived input, and a variable reassigned only on some paths
|
|
# isn't a barrier CodeQL's flow analysis honors. `trusted_dir` is the
|
|
# sole name used below, always the scandir-sourced result.
|
|
trusted_dir = _trusted_plugin_dir(candidate_dir) if candidate_dir else None
|
|
if not trusted_dir:
|
|
raise LookupError(f'Plugin not found: {plugin_id}')
|
|
|
|
manifest_path = trusted_dir / 'manifest.json'
|
|
with open(manifest_path, 'r') as f:
|
|
manifest = json.load(f)
|
|
|
|
# Build config: schema defaults + user overrides
|
|
config = {'enabled': True}
|
|
config.update(load_config_defaults(trusted_dir))
|
|
config.update(data.get('config', {}))
|
|
|
|
return trusted_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False)
|
|
|
|
|
|
@app.route('/api/render', methods=['POST'])
|
|
def api_render():
|
|
"""Render a plugin and return the display as base64 PNG."""
|
|
data = request.get_json()
|
|
if not data or 'plugin_id' not in data:
|
|
return jsonify({'error': 'plugin_id is required'}), 400
|
|
|
|
try:
|
|
width = int(data.get('width', 128))
|
|
height = int(data.get('height', 32))
|
|
except (TypeError, ValueError):
|
|
return jsonify({'error': 'width and height must be integers'}), 400
|
|
|
|
if not (MIN_WIDTH <= width <= MAX_WIDTH):
|
|
return jsonify({'error': f'width must be between {MIN_WIDTH} and {MAX_WIDTH}'}), 400
|
|
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
|
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
|
|
|
try:
|
|
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
|
except LookupError:
|
|
return jsonify({'error': f"Plugin not found: {data['plugin_id']}"}), 404
|
|
except Exception:
|
|
# Bad manifest.json / schema / fixture — details go to the dev's
|
|
# console, not the HTTP response
|
|
app.logger.exception('render request preparation failed')
|
|
return jsonify({'error': 'Could not prepare render request; see server log'}), 400
|
|
|
|
try:
|
|
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
|
|
mock_data, width, height, skip_update)
|
|
except Exception:
|
|
app.logger.exception('plugin load failed during render')
|
|
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
|
|
return jsonify(result)
|
|
|
|
|
|
@app.route('/api/sizes')
|
|
def api_sizes():
|
|
"""The representative panel-size sample the safety harness renders at."""
|
|
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
|
return jsonify({'sizes': [list(s) for s in DEFAULT_TEST_SIZES]})
|
|
|
|
|
|
MAX_MATRIX_SIZES = 12
|
|
|
|
|
|
@app.route('/api/render-matrix', methods=['POST'])
|
|
def api_render_matrix():
|
|
"""Render a plugin at a list of sizes (default: the harness sample) so the
|
|
UI can show a side-by-side multi-resolution gallery."""
|
|
data = request.get_json()
|
|
if not data or 'plugin_id' not in data:
|
|
return jsonify({'error': 'plugin_id is required'}), 400
|
|
|
|
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
|
sizes = data.get('sizes') or [list(s) for s in DEFAULT_TEST_SIZES]
|
|
if len(sizes) > MAX_MATRIX_SIZES:
|
|
return jsonify({'error': f'at most {MAX_MATRIX_SIZES} sizes per request'}), 400
|
|
parsed_sizes = []
|
|
for pair in sizes:
|
|
try:
|
|
w, h = int(pair[0]), int(pair[1])
|
|
except (TypeError, ValueError, IndexError):
|
|
return jsonify({'error': f'invalid size entry {pair!r} (expected [w, h])'}), 400
|
|
if not (MIN_WIDTH <= w <= MAX_WIDTH and MIN_HEIGHT <= h <= MAX_HEIGHT):
|
|
return jsonify({'error': f'size {w}x{h} out of bounds'}), 400
|
|
parsed_sizes.append((w, h))
|
|
|
|
try:
|
|
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
|
except LookupError:
|
|
return jsonify({'error': f"Plugin not found: {data['plugin_id']}"}), 404
|
|
except Exception:
|
|
app.logger.exception('render request preparation failed')
|
|
return jsonify({'error': 'Could not prepare render request; see server log'}), 400
|
|
|
|
results = []
|
|
for w, h in parsed_sizes:
|
|
try:
|
|
results.append(_render_once(data['plugin_id'], plugin_dir, manifest,
|
|
config, mock_data, w, h, skip_update))
|
|
except Exception:
|
|
app.logger.exception('plugin load failed during %dx%d render', w, h)
|
|
results.append({'image': None, 'width': w, 'height': h,
|
|
'render_time_ms': 0,
|
|
'errors': ['Failed to load plugin; see server log'],
|
|
'warnings': []})
|
|
return jsonify({'results': results})
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Main
|
|
# --------------------------------------------------------------------------
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='LEDMatrix Dev Preview Server')
|
|
parser.add_argument('--port', type=int, default=5001, help='Port to run on (default: 5001)')
|
|
parser.add_argument('--host', default='127.0.0.1', help='Host to bind to (default: 127.0.0.1)')
|
|
parser.add_argument('--extra-dir', action='append', default=[],
|
|
help='Extra plugin directory to search (can be repeated)')
|
|
parser.add_argument('--debug', action='store_true', help='Enable Flask debug mode')
|
|
|
|
args = parser.parse_args()
|
|
|
|
global _extra_dirs
|
|
_extra_dirs = args.extra_dir
|
|
|
|
print(f"LEDMatrix Dev Preview Server")
|
|
print(f"Open http://{args.host}:{args.port} in your browser")
|
|
print(f"Plugin search dirs: {[str(d) for d in get_search_dirs()]}")
|
|
print()
|
|
|
|
app.run(host=args.host, port=args.port, debug=args.debug)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|