From e319540c6e43f4f2bd930288cef6300aa2f85549 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Tue, 14 Jul 2026 16:27:50 -0400 Subject: [PATCH 01/15] feat(web): add Plugin Composer -- visual drag-and-drop plugin builder Web UI (/composer/) for building a working LEDMatrix plugin without writing Python: drop elements (text, time, date, countdown, scrolling text, bar/waveform, groups, custom config variables) onto a canvas matching the real panel's pixel grid, configure them with live preview, then generate a real plugin (manager.py + manifest.json + config_schema.json) from manager.py.j2 -- downloadable as a ZIP or installed directly. NOTE: composer_bp is not yet registered in web_interface/app.py, so this blueprint is currently inert. Split out of the original chore/dead-code- removal commit, which had accidentally bundled this in alongside unrelated dead-code deletions; app.py registration was not part of that commit either and still needs to be added before this is reachable. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- web_interface/blueprints/composer.py | 837 ++++++++ .../static/v3/js/composer/composer-app.js | 1337 ++++++++++++ .../static/v3/js/composer/composer-canvas.js | 757 +++++++ web_interface/templates/v3/composer.html | 1852 +++++++++++++++++ .../templates/v3/composer/manager.py.j2 | 298 +++ 5 files changed, 5081 insertions(+) create mode 100644 web_interface/blueprints/composer.py create mode 100644 web_interface/static/v3/js/composer/composer-app.js create mode 100644 web_interface/static/v3/js/composer/composer-canvas.js create mode 100644 web_interface/templates/v3/composer.html create mode 100644 web_interface/templates/v3/composer/manager.py.j2 diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py new file mode 100644 index 00000000..a6e19374 --- /dev/null +++ b/web_interface/blueprints/composer.py @@ -0,0 +1,837 @@ +""" +Plugin Composer blueprint — drag-and-drop plugin builder for LEDMatrix. + +Routes: + GET /composer/ — Composer page + POST /composer/api/generate — Generate and return plugin ZIP + POST /composer/api/install — Write plugin directly to plugins_dir + GET /composer/api/fonts/ — Serve TTF font files for canvas rendering + GET /composer/api/validate-id/ — Check if a plugin ID is already taken +""" +import ast +import io +import json +import logging +import re +import zipfile +from datetime import datetime +from pathlib import Path + +import jinja2 +import jsonschema +from flask import Blueprint, jsonify, render_template, request, send_file + +logger = logging.getLogger(__name__) + +composer_bp = Blueprint('composer', __name__) + +# Module-level attributes injected by app.py at registration time +composer_bp.config_manager = None +composer_bp.plugin_manager = None +composer_bp.plugins_dir = None +composer_bp.project_root = None + +# Fonts safe to serve to the browser for canvas rendering +_ALLOWED_FONTS = frozenset({'PressStart2P-Regular.ttf', '4x6-font.ttf', '5by7.regular.ttf'}) + +# Map composer font keys → DisplayManager attribute names +_FONT_ATTR_MAP = { + 'press_start': 'regular_font', + 'four_by_six': 'extra_small_font', + 'five_by_seven': 'bdf_5x7_font', +} + +# Font sizes in LED pixels (used to compute second-line Y offsets) +_FONT_SIZE_MAP = { + 'press_start': 8, + 'four_by_six': 6, + 'five_by_seven': 7, +} + +_PLUGIN_ID_RE = re.compile(r'^[a-z][a-z0-9-]{0,62}$') +_PYTHON_IDENT_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$') + +# ── Jinja2 environment (separate from Flask's; autoescape=False for code gen) ── + +_jinja_env: jinja2.Environment | None = None + + +def _get_jinja_env() -> jinja2.Environment: + global _jinja_env + if _jinja_env is None: + template_dir = Path(__file__).parent.parent / 'templates' / 'v3' / 'composer' + _jinja_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(str(template_dir)), + autoescape=False, + trim_blocks=True, + lstrip_blocks=True, + ) + _jinja_env.filters['as_rgb'] = _as_rgb_filter + _jinja_env.filters['as_fill'] = _as_fill_filter + return _jinja_env + + +def _as_rgb_filter(val) -> str: + """[r, g, b] → '(r, g, b)'""" + if val is None: + return 'None' + return f'({int(val[0])}, {int(val[1])}, {int(val[2])})' + + +def _as_fill_filter(val) -> str: + """[r, g, b] or None → '(r, g, b)' or 'None'""" + if val is None: + return 'None' + return _as_rgb_filter(val) + + +# ── Helper functions ────────────────────────────────────────────────────────── + +def _to_class_name(name: str) -> str: + """'My Clock' → 'MyClockPlugin' (avoids double-suffix if name already ends with Plugin)""" + words = re.sub(r'[^a-zA-Z0-9]', ' ', name).split() + base = ''.join(w.capitalize() for w in words) + return base if base.endswith('Plugin') else base + 'Plugin' + + +def _compute_pos_expr(val: int, anchor: str | None, dim_var: str) -> str: + """Produce a Python expression string for an anchored or fixed position. + + anchor=None/'left'/'top' → fixed pixel value + anchor='center' → dim_var // 2 ± offset + anchor='right'/'bottom' → dim_var - offset + """ + if not anchor or anchor in ('left', 'top'): + return str(val) + if anchor in ('center', 'middle'): + if val == 0: + return f"{dim_var} // 2" + return f"{dim_var} // 2 + {val}" if val > 0 else f"{dim_var} // 2 - {abs(val)}" + if anchor in ('right', 'bottom'): + return dim_var if val == 0 else f"{dim_var} - {val}" + return str(val) + + +# Character widths in LED pixels per font (for text-alignment x offset math) +_FONT_CHAR_W = { + 'press_start': 8, + 'four_by_six': 4, + 'five_by_seven': 5, +} + + +def _aligned_x_expr(x_base_expr: str, text_align: str, char_count: int, char_w: int) -> str: + """Return Python x expression for text alignment. + + left → x_base_expr (no change) + center → x_base_expr - half_text_width + right → x_base_expr - text_width + """ + if text_align == 'left' or not text_align: + return x_base_expr + text_px = char_count * char_w + if text_align == 'center': + offset = text_px // 2 + return f"({x_base_expr}) - {offset}" if offset else x_base_expr + if text_align == 'right': + return f"({x_base_expr}) - {text_px}" if text_px else x_base_expr + return x_base_expr + + +def _preprocess_elements(elements: list) -> list: + """Expand raw element dicts into template-ready dicts with anchor expressions. + + Invisible elements (visible=False) are excluded from generated code entirely. + """ + result = [] + for el in elements: + # Skip hidden elements — they exist only in the preview + if el.get('visible') is False: + continue + + p = dict(el) + t = el.get('type', '') + + # Section elements are layer-list annotations only — no canvas output + if t == 'section': + continue + + x_anchor = el.get('xAnchor') or None + y_anchor = el.get('yAnchor') or None + p['min_width'] = int(el.get('minWidth', 0) or 0) + + if t in ('text', 'clock'): + font_key = el.get('font', 'press_start') + p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font') + p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})" + text_align = el.get('textAlign', 'left') + raw_x = el.get('x', 0) + x_base_expr = _compute_pos_expr(raw_x, x_anchor, 'width') + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + font_size = _FONT_SIZE_MAP.get(font_key, 8) + char_w = _FONT_CHAR_W.get(font_key, 8) + line_spacing = int(el.get('lineSpacing', 2)) + y_expr = p['y_expr'] + p['y2_expr'] = f"({y_expr}) + {font_size + line_spacing}" + if t == 'text': + t1 = el.get('text', '') or '' + t2 = el.get('text2', '') or '' + p['text2'] = t2 + # Detect {variable} tokens — generate format_map() call instead of literal + _var_re = re.compile(r'\{([a-zA-Z_]\w*)\}') + p['text_is_template'] = bool(_var_re.search(t1) or _var_re.search(t2)) + ref_len = max(len(t1), len(t2)) if t2 else len(t1) + p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w) + p['x2_expr'] = p['x_expr'] # second line uses same x + else: # clock + fmt1 = el.get('format', '%H:%M') or '%H:%M' + fmt2 = el.get('format2', '') or '' + p['format2'] = fmt2 + ref_len = max(len(fmt1), len(fmt2)) if fmt2 else len(fmt1) + p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w) + p['x2_expr'] = p['x_expr'] + p['blink'] = bool(el.get('blink', False)) + + elif t == 'dynamic_text': + binding = el.get('binding', {}) + p['binding_source'] = binding.get('source', 'config') + p['binding_key'] = binding.get('key', '') + p['binding_format'] = binding.get('format') + font_key = el.get('font', 'press_start') + p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font') + p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 100)})" + x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + p['x_expr'] = x_base_expr # dynamic text: runtime content determines width; use raw pos + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['blink'] = bool(el.get('blink', False)) + + elif t == 'rectangle': + x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + w = el.get('width', 10) + h = el.get('height', 8) + p['x_expr'] = x_expr + p['y_expr'] = y_expr + # x2/y2 as runtime expressions to support anchored positions + p['x2_expr'] = f"({x_expr}) + {w}" + p['y2_expr'] = f"({y_expr}) + {h}" + fill = ( + [el.get('fillR', 0), el.get('fillG', 0), el.get('fillB', 128)] + if el.get('hasFill', True) else None + ) + outline = ( + [el.get('outR', 255), el.get('outG', 255), el.get('outB', 255)] + if el.get('hasOutline', True) else None + ) + p['fill_tuple'] = _as_fill_filter(fill) + p['outline_tuple'] = _as_fill_filter(outline) + p['blink'] = bool(el.get('blink', False)) + + elif t in ('line', 'divider'): + if t == 'divider': + orient = el.get('orientation', 'horizontal') + if orient == 'horizontal': + y_val = el.get('y', 16) + y_expr = _compute_pos_expr(y_val, y_anchor, 'height') + p.update(x0_expr='0', y0_expr=y_expr, x1_expr='width - 1', y1_expr=y_expr) + else: + x_val = el.get('x', 64) + x_expr = _compute_pos_expr(x_val, x_anchor, 'width') + p.update(x0_expr=x_expr, y0_expr='0', x1_expr=x_expr, y1_expr='height - 1') + else: + p['x0_expr'] = _compute_pos_expr(el.get('x0', 0), x_anchor, 'width') + p['y0_expr'] = _compute_pos_expr(el.get('y0', 0), y_anchor, 'height') + p['x1_expr'] = str(el.get('x1', 127)) + p['y1_expr'] = str(el.get('y1', 0)) + p['rgb_tuple'] = f"({el.get('r', 180)}, {el.get('g', 180)}, {el.get('b', 180)})" + p['line_width'] = el.get('lineWidth', 1) + p['blink'] = bool(el.get('blink', False)) + + elif t == 'progress_bar': + p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['bar_width'] = int(el.get('barWidth', 40)) + p['bar_height'] = int(el.get('barHeight', 6)) + binding = el.get('binding', {}) + p['binding_key'] = binding.get('key', '') + p['fill_tuple'] = f"({el.get('r', 100)}, {el.get('g', 200)}, {el.get('b', 100)})" + bg = ( + [el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)] + if el.get('hasBg', True) else None + ) + outline = ( + [el.get('outR', 100), el.get('outG', 100), el.get('outB', 100)] + if el.get('hasOutline', True) else None + ) + p['bg_tuple'] = _as_fill_filter(bg) + p['outline_tuple'] = _as_fill_filter(outline) + p['blink'] = bool(el.get('blink', False)) + + elif t == 'arc': + x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + w = el.get('width', 24) + h = el.get('height', 24) + p['x_expr'] = x_expr + p['y_expr'] = y_expr + p['x2_expr'] = f"({x_expr}) + {w}" + p['y2_expr'] = f"({y_expr}) + {h}" + p['start_angle'] = int(el.get('startAngle', 0)) + p['end_angle'] = int(el.get('endAngle', 270)) + p['line_width'] = max(1, int(el.get('lineWidth', 2))) + p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})" + p['blink'] = bool(el.get('blink', False)) + + elif t == 'ellipse': + x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + w = el.get('width', 24) + h = el.get('height', 12) + p['x_expr'] = x_expr + p['y_expr'] = y_expr + p['x2_expr'] = f"({x_expr}) + {w}" + p['y2_expr'] = f"({y_expr}) + {h}" + fill = ( + [el.get('fillR', 0), el.get('fillG', 100), el.get('fillB', 200)] + if el.get('hasFill', True) else None + ) + outline = ( + [el.get('outR', 100), el.get('outG', 180), el.get('outB', 255)] + if el.get('hasOutline', True) else None + ) + p['fill_tuple'] = _as_fill_filter(fill) + p['outline_tuple'] = _as_fill_filter(outline) + p['blink'] = bool(el.get('blink', False)) + + elif t == 'pixel': + p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})" + p['blink'] = bool(el.get('blink', False)) + + elif t == 'rounded_rectangle': + x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + w = el.get('width', 24) + h = el.get('height', 10) + p['x_expr'] = x_expr + p['y_expr'] = y_expr + p['x2_expr'] = f"({x_expr}) + {w}" + p['y2_expr'] = f"({y_expr}) + {h}" + p['border_radius'] = int(el.get('borderRadius', 3)) + fill = ( + [el.get('fillR', 0), el.get('fillG', 80), el.get('fillB', 180)] + if el.get('hasFill', True) else None + ) + outline = ( + [el.get('outR', 120), el.get('outG', 180), el.get('outB', 255)] + if el.get('hasOutline', True) else None + ) + p['fill_tuple'] = _as_fill_filter(fill) + p['outline_tuple'] = _as_fill_filter(outline) + p['blink'] = bool(el.get('blink', False)) + + elif t == 'countdown': + font_key = el.get('font', 'four_by_six') + p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font') + p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 180)}, {el.get('b', 0)})" + binding = el.get('binding', {}) + p['binding_key'] = binding.get('key', '') + p['countdown_format'] = el.get('countdownFormat', 'dh') + x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + p['x_expr'] = x_base_expr + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['blink'] = bool(el.get('blink', False)) + + elif t == 'pips': + p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['pip_count'] = max(1, int(el.get('count', 5))) + p['pip_size'] = max(1, int(el.get('pipSize', 4))) + p['pip_spacing'] = max(0, int(el.get('pipSpacing', 2))) + p['show_empty'] = bool(el.get('showEmpty', True)) + binding = el.get('binding', {}) + p['binding_key'] = binding.get('key', '') + p['fill_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})" + p['empty_tuple'] = f"({el.get('emptyR', 50)}, {el.get('emptyG', 50)}, {el.get('emptyB', 50)})" + p['blink'] = bool(el.get('blink', False)) + + elif t == 'sparkline': + x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['x_expr'] = x_expr + p['y_expr'] = y_expr + p['bar_width_px'] = int(el.get('width', 40)) + p['bar_height_px'] = int(el.get('height', 12)) + p['bar_count'] = max(1, int(el.get('barCount', 8))) + p['bar_spacing'] = max(0, int(el.get('barSpacing', 1))) + binding = el.get('binding', {}) + p['binding_key'] = binding.get('key', '') + p['fill_tuple'] = f"({el.get('r', 80)}, {el.get('g', 200)}, {el.get('b', 120)})" + bg = [el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)] if el.get('hasBg', False) else None + p['bg_tuple'] = _as_fill_filter(bg) + p['blink'] = bool(el.get('blink', False)) + + elif t == 'gauge': + x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + w = el.get('width', 32) + h = el.get('height', 32) + p['x_expr'] = x_expr + p['y_expr'] = y_expr + p['x2_expr'] = f"({x_expr}) + {w}" + p['y2_expr'] = f"({y_expr}) + {h}" + p['start_angle'] = int(el.get('startAngle', 135)) + p['end_angle'] = int(el.get('endAngle', 45)) + p['line_width'] = max(1, int(el.get('lineWidth', 3))) + p['rgb_tuple'] = f"({el.get('r', 80)}, {el.get('g', 220)}, {el.get('b', 80)})" + track = ( + [el.get('trackR', 40), el.get('trackG', 40), el.get('trackB', 40)] + if el.get('hasTrack', True) else None + ) + p['track_tuple'] = _as_fill_filter(track) + binding = el.get('binding', {}) + p['binding_key'] = binding.get('key', '') + font_key = el.get('font', 'four_by_six') + p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font') + p['show_label'] = bool(el.get('showLabel', True)) + p['label_tuple'] = f"({el.get('labelR', 200)}, {el.get('labelG', 200)}, {el.get('labelB', 200)})" + p['blink'] = bool(el.get('blink', False)) + + elif t == 'marquee': + font_key = el.get('font', 'press_start') + p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font') + p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})" + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['text'] = el.get('text', 'Scrolling text') + p['char_w'] = _FONT_CHAR_W.get(font_key, 8) + p['gap'] = int(el.get('gap', 16)) + p['scroll_speed'] = max(1, int(el.get('scrollSpeed', 1))) + p['direction'] = el.get('direction', 'left') + # Data key stored in self._data for stateful scrolling across display() calls + raw_id = str(el.get('id', 0)).replace('-', '_') + p['data_key'] = f"mq_{raw_id}" + p['blink'] = bool(el.get('blink', False)) + + result.append(p) + return result + + +def _generate_plugin_files(data: dict) -> dict: + """ + Generate all plugin file contents as strings. + + Returns dict: {'manager.py', 'manifest.json', 'config_schema.json', 'requirements.txt'} + Raises ValueError with a human-readable message on any validation failure. + """ + metadata = data.get('metadata', {}) + elements = data.get('elements', []) + data_model = data.get('dataModel', {}) + config_vars = data_model.get('configVars', []) + + plugin_id = metadata.get('id', '').strip() + if not _PLUGIN_ID_RE.match(plugin_id): + raise ValueError( + 'Plugin ID must start with a lowercase letter and contain only ' + 'lowercase letters, numbers, and hyphens (max 63 chars).' + ) + + plugin_name = metadata.get('name', '').strip() + if not plugin_name: + raise ValueError('Plugin name is required.') + + author = metadata.get('author', '').strip() + if not author: + raise ValueError('Author is required.') + + version = metadata.get('version', '1.0.0').strip() + + # Validate config var keys are valid Python identifiers + for cv in config_vars: + key = cv.get('key', '') + if not _PYTHON_IDENT_RE.match(key): + raise ValueError(f'Config variable key "{key}" is not a valid Python identifier.') + + class_name = _to_class_name(plugin_name) + # Only consider visible elements for code generation flags + visible_elements = [e for e in elements if e.get('visible') is not False] + processed = _preprocess_elements(elements) + has_clock = any(e.get('type') == 'clock' for e in visible_elements) + has_blink = any(e.get('blink') for e in visible_elements) + has_countdown = any(e.get('type') == 'countdown' for e in visible_elements) + _var_re = re.compile(r'\{[a-zA-Z_]\w*\}') + has_text_template = any( + e.get('type') == 'text' and ( + _var_re.search(e.get('text', '') or '') or + _var_re.search(e.get('text2', '') or '') + ) + for e in visible_elements + ) + + # Background fill color (None → don't render, use LED panel's native black) + bg_color: str | None = None + bg_raw = metadata.get('bgColor') + if isinstance(bg_raw, dict): + r, g, b = int(bg_raw.get('r', 0)), int(bg_raw.get('g', 0)), int(bg_raw.get('b', 0)) + if r or g or b: + bg_color = f'({r}, {g}, {b})' + + # Render manager.py + env = _get_jinja_env() + try: + tmpl = env.get_template('manager.py.j2') + except jinja2.TemplateNotFound: + raise ValueError('Code generation template not found. This is a server configuration issue.') + + manager_py = tmpl.render( + plugin_name=plugin_name, + class_name=class_name, + plugin_id=plugin_id, + generated_date=datetime.now().strftime('%Y-%m-%d'), + config_vars=config_vars, + elements=processed, + has_clock=has_clock, + has_blink=has_blink, + has_countdown=has_countdown, + has_text_template=has_text_template, + bg_color=bg_color, + ) + + # Syntax-check the generated Python + try: + ast.parse(manager_py) + except SyntaxError as exc: + raise ValueError(f'Generated code has a syntax error: {exc}') from exc + + # Build manifest + manifest = { + 'id': plugin_id, + 'name': plugin_name, + 'version': version, + 'author': author, + 'description': metadata.get('description', f'Custom plugin created with LEDMatrix Plugin Composer'), + 'category': metadata.get('category', 'custom'), + 'tags': ['composer', 'custom'], + 'entry_point': 'manager.py', + 'class_name': class_name, + 'display_modes': [plugin_id], + 'compatible_versions': ['>=2.0.0'], + 'last_updated': datetime.now().strftime('%Y-%m-%d'), + 'update_interval': int(metadata.get('update_interval', 60)), + 'default_duration': float(metadata.get('display_duration', 15)), + 'versions': [ + {'released': datetime.now().strftime('%Y-%m-%d'), 'version': version} + ], + } + + # Validate manifest against the project's schema + if composer_bp.project_root: + schema_path = Path(composer_bp.project_root) / 'schema' / 'manifest_schema.json' + if schema_path.exists(): + schema = json.loads(schema_path.read_text()) + validator = jsonschema.Draft7Validator(schema) + errors = list(validator.iter_errors(manifest)) + if errors: + msgs = '; '.join(e.message for e in errors[:3]) + raise ValueError(f'Manifest validation failed: {msgs}') + + # Build config_schema + type_map = { + 'string': {'type': 'string'}, + 'number': {'type': 'number', 'minimum': 0}, + 'boolean': {'type': 'boolean'}, + 'color': { + 'type': 'array', + 'items': {'type': 'integer', 'minimum': 0, 'maximum': 255}, + 'minItems': 3, + 'maxItems': 3, + }, + } + + config_properties = { + 'enabled': {'type': 'boolean', 'default': True}, + 'display_duration': {'type': 'number', 'minimum': 1, 'default': float(metadata.get('display_duration', 15))}, + } + for cv in config_vars: + cv_type = cv.get('type', 'string') + prop = dict(type_map.get(cv_type, {'type': 'string'})) + if cv.get('description'): + prop['description'] = cv['description'] + if cv.get('label'): + prop['title'] = cv['label'] + default = cv.get('default', '') + if cv_type == 'number': + try: + prop['default'] = float(default) if default != '' else 0 + except (TypeError, ValueError): + prop['default'] = 0 + elif cv_type == 'boolean': + prop['default'] = bool(default) + else: + prop['default'] = default + config_properties[cv['key']] = prop + + config_schema = { + '$schema': 'http://json-schema.org/draft-07/schema#', + 'type': 'object', + 'properties': config_properties, + } + + return { + 'manager.py': manager_py, + 'manifest.json': json.dumps(manifest, indent=2), + 'config_schema.json': json.dumps(config_schema, indent=2), + 'requirements.txt': '', + } + + +def _save_composer_state(target_dir: Path, payload: dict) -> None: + """Persist the raw composer payload alongside the generated plugin files.""" + (target_dir / '_composer_state.json').write_text( + json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8' + ) + + +def _pack_zip(files: dict, plugin_id: str) -> io.BytesIO: + """Pack generated plugin files into an in-memory ZIP.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_DEFLATED) as zf: + for filename, content in files.items(): + info = zipfile.ZipInfo(f'{plugin_id}/{filename}') + info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(info, content.encode('utf-8') if isinstance(content, str) else content) + buf.seek(0) + return buf + + +# ── Routes ──────────────────────────────────────────────────────────────────── + +@composer_bp.route('/') +def index(): + return render_template('v3/composer.html') + + +@composer_bp.route('/api/generate', methods=['POST']) +def generate_zip(): + data = request.get_json(force=True, silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No JSON body'}), 400 + try: + files = _generate_plugin_files(data) + except ValueError as exc: + return jsonify({'status': 'error', 'message': str(exc)}), 422 + + plugin_id = data.get('metadata', {}).get('id', 'plugin') + files['_composer_state.json'] = json.dumps(data, indent=2, ensure_ascii=False) + zip_buf = _pack_zip(files, plugin_id) + return send_file( + zip_buf, + mimetype='application/zip', + as_attachment=True, + download_name=f'{plugin_id}.zip', + ) + + +@composer_bp.route('/api/install', methods=['POST']) +def install_locally(): + if not composer_bp.plugins_dir: + return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503 + + data = request.get_json(force=True, silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No JSON body'}), 400 + + try: + files = _generate_plugin_files(data) + except ValueError as exc: + return jsonify({'status': 'error', 'message': str(exc)}), 422 + + plugin_id = data.get('metadata', {}).get('id', '') + target = Path(composer_bp.plugins_dir) / plugin_id + force = bool(data.get('_force', False)) + + if target.exists() and not force: + return jsonify({ + 'status': 'conflict', + 'message': f'Plugin "{plugin_id}" is already installed.', + }), 409 + + try: + if target.exists() and force: + import shutil as _shutil + _shutil.rmtree(target) + target.mkdir(parents=True, exist_ok=False) + for filename, content in files.items(): + (target / filename).write_text(content, encoding='utf-8') + _save_composer_state(target, data) + except OSError as exc: + return jsonify({'status': 'error', 'message': f'Failed to write plugin files: {exc}'}), 500 + + # Trigger plugin discovery so it shows up in the Plugin Manager immediately + if composer_bp.plugin_manager: + try: + composer_bp.plugin_manager.discover_plugins() + except Exception as exc: + logger.warning('discover_plugins() failed after composer install: %s', exc) + + return jsonify({ + 'status': 'success', + 'message': f'Plugin "{plugin_id}" installed successfully.', + 'plugin_id': plugin_id, + }) + + +@composer_bp.route('/api/fonts/') +def serve_font(font_name): + """Serve an allowlisted font file for canvas FontFace loading.""" + if font_name not in _ALLOWED_FONTS: + return '', 404 + if not composer_bp.project_root: + return '', 503 + font_path = Path(composer_bp.project_root) / 'assets' / 'fonts' / font_name + if not font_path.exists(): + return '', 404 + return send_file(str(font_path), mimetype='font/ttf') + + +@composer_bp.route('/api/validate-id/') +def validate_id(plugin_id): + """Check whether a plugin ID is valid and available.""" + if not _PLUGIN_ID_RE.match(plugin_id): + return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'}) + if composer_bp.plugins_dir: + taken = (Path(composer_bp.plugins_dir) / plugin_id).exists() + if taken: + return jsonify({'valid': True, 'available': False, 'reason': 'Already installed'}) + return jsonify({'valid': True, 'available': True}) + + +@composer_bp.route('/api/plugins') +def list_plugins(): + """List installed plugins, flagging which ones have a saved composer state.""" + if not composer_bp.plugins_dir: + return jsonify([]) + plugins_dir = Path(composer_bp.plugins_dir) + results = [] + for entry in sorted(plugins_dir.iterdir()): + if not entry.is_dir(): + continue + manifest_path = entry / 'manifest.json' + if not manifest_path.exists(): + continue + try: + manifest = json.loads(manifest_path.read_text()) + except Exception: + continue + has_state = (entry / '_composer_state.json').exists() + results.append({ + 'id': manifest.get('id', entry.name), + 'name': manifest.get('name', entry.name), + 'version': manifest.get('version', ''), + 'author': manifest.get('author', ''), + 'has_composer_state': has_state, + }) + return jsonify(results) + + +@composer_bp.route('/api/preview', methods=['POST']) +def preview_code(): + """Generate plugin files and return them as JSON for the code preview modal.""" + data = request.get_json(force=True, silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No JSON body'}), 400 + try: + files = _generate_plugin_files(data) + except ValueError as exc: + return jsonify({'status': 'error', 'message': str(exc)}), 422 + return jsonify({ + 'status': 'ok', + 'files': { + 'manager.py': files['manager.py'], + 'manifest.json': files['manifest.json'], + 'config_schema.json': files['config_schema.json'], + }, + }) + + +@composer_bp.route('/api/load/') +def load_plugin(plugin_id): + """Load a plugin's composer state for editing. + + If a _composer_state.json exists, return it verbatim. + Otherwise, extract config vars from config_schema.json for a partial import. + """ + if not composer_bp.plugins_dir: + return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503 + if not _PLUGIN_ID_RE.match(plugin_id): + return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400 + + plugin_dir = Path(composer_bp.plugins_dir) / plugin_id + if not plugin_dir.exists(): + return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 + + # Full composer state + state_path = plugin_dir / '_composer_state.json' + if state_path.exists(): + try: + state = json.loads(state_path.read_text()) + return jsonify({'status': 'ok', 'source': 'composer', 'state': state}) + except Exception as exc: + return jsonify({'status': 'error', 'message': f'Failed to read state: {exc}'}), 500 + + # Partial import from config_schema.json + schema_path = plugin_dir / 'config_schema.json' + manifest_path = plugin_dir / 'manifest.json' + config_vars = [] + + if schema_path.exists(): + try: + schema = json.loads(schema_path.read_text()) + props = schema.get('properties', {}) + skip = {'enabled', 'display_duration', 'update_interval'} + type_map = {'boolean': 'boolean', 'number': 'number', 'integer': 'number', 'string': 'string'} + for key, prop in props.items(): + if key in skip: + continue + prop_type = prop.get('type', 'string') + if isinstance(prop_type, list): + prop_type = next((t for t in prop_type if t != 'null'), 'string') + # Detect color arrays + if prop_type == 'array' and prop.get('maxItems') == 3: + cv_type = 'color' + else: + cv_type = type_map.get(prop_type, 'string') + config_vars.append({ + 'key': key, + 'label': prop.get('title', key.replace('_', ' ').title()), + 'type': cv_type, + 'default': prop.get('default', ''), + 'description': prop.get('description', ''), + }) + except Exception: + pass + + manifest = {} + if manifest_path.exists(): + try: + manifest = json.loads(manifest_path.read_text()) + except Exception: + pass + + partial_state = { + 'composer_version': '1.0', + 'metadata': { + 'id': manifest.get('id', plugin_id), + 'name': manifest.get('name', plugin_id), + 'author': manifest.get('author', ''), + 'version': manifest.get('version', '1.0.0'), + 'description': manifest.get('description', ''), + 'category': manifest.get('category', 'custom'), + 'display_duration': manifest.get('default_duration', 15), + 'update_interval': manifest.get('update_interval', 60), + 'api_requirements': manifest.get('api_requirements', []), + }, + 'elements': [], + 'dataModel': {'configVars': config_vars, 'dataSources': [], 'computedVars': []}, + } + return jsonify({'status': 'ok', 'source': 'schema_import', 'state': partial_state}) diff --git a/web_interface/static/v3/js/composer/composer-app.js b/web_interface/static/v3/js/composer/composer-app.js new file mode 100644 index 00000000..f0124371 --- /dev/null +++ b/web_interface/static/v3/js/composer/composer-app.js @@ -0,0 +1,1337 @@ +/** + * composerApp() — AlpineJS component for the Plugin Composer. + * + * Key architecture notes: + * - All mutations call _snapshot() for undo/redo support + * - Elements with anchors: drag stores offset from anchor, not raw px + * - localStorage autosaves on every mutation (debounced 1.5s) + * - composer_version in payload allows future server-side migration + */ + +// ── Template library ───────────────────────────────────────────────────────── +const COMPOSER_TEMPLATES = [ + { + id: 'blank', + label: 'Blank Canvas', + description: 'Start from scratch', + icon: 'fas fa-plus-square', + preset: '128×32', + elements: [], + dataModel: { configVars: [], dataSources: [], computedVars: [] }, + }, + { + id: 'clock', + label: 'Digital Clock', + description: 'Large clock with date below a divider', + icon: 'fas fa-clock', + preset: '128×32', + elements: [ + { id:1, type:'clock', x:44, y:4, format:'%H:%M', font:'press_start', r:100, g:255, b:100, xAnchor:null, yAnchor:null, minWidth:0, label:'time', conditions:[] }, + { id:2, type:'divider', x:0, y:18, orientation:'horizontal', r:50, g:50, b:50, xAnchor:null, yAnchor:null, minWidth:0, label:'', conditions:[] }, + { id:3, type:'clock', x:32, y:22, format:'%A %b %d', font:'four_by_six', r:150, g:150, b:150, xAnchor:null, yAnchor:null, minWidth:0, label:'date', conditions:[] }, + ], + dataModel: { configVars:[], dataSources:[], computedVars:[] }, + }, + { + id: 'announcement', + label: 'Announcement', + description: 'Static title + configurable message text', + icon: 'fas fa-bullhorn', + preset: '128×32', + elements: [ + { id:1, type:'text', x:4, y:2, text:'ANNOUNCEMENT', font:'four_by_six', r:255, g:200, b:0, xAnchor:null, yAnchor:null, minWidth:0, label:'title', conditions:[] }, + { id:2, type:'divider', x:0, y:11, orientation:'horizontal', r:80, g:80, b:0, xAnchor:null, yAnchor:null, minWidth:0, label:'', conditions:[] }, + { id:3, type:'dynamic_text', x:4, y:15, binding:{source:'config',key:'message',format:null}, font:'four_by_six', r:255, g:255, b:255, xAnchor:null, yAnchor:null, minWidth:0, label:'message', conditions:[] }, + ], + dataModel: { + configVars:[{ key:'message', label:'Message', type:'string', default:'Hello World!', description:'Message to display' }], + dataSources:[], computedVars:[], + }, + }, + { + id: 'scoreboard', + label: 'Scoreboard', + description: 'Two-team scores with status line', + icon: 'fas fa-trophy', + preset: '128×32', + elements: [ + { id:1, type:'dynamic_text', x:2, y:2, binding:{source:'config',key:'home_team',format:null}, font:'four_by_six', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'home team', conditions:[] }, + { id:2, type:'dynamic_text', x:2, y:12, binding:{source:'config',key:'home_score',format:null}, font:'press_start', r:255,g:220,b:50, xAnchor:null,yAnchor:null,minWidth:0, label:'home score', conditions:[] }, + { id:3, type:'divider', x:64, y:0, orientation:'vertical', r:60,g:60,b:60, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, + { id:4, type:'dynamic_text', x:68, y:2, binding:{source:'config',key:'away_team',format:null}, font:'four_by_six', r:200,g:200,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'away team', conditions:[] }, + { id:5, type:'dynamic_text', x:68, y:12, binding:{source:'config',key:'away_score',format:null}, font:'press_start', r:100,g:180,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'away score', conditions:[] }, + { id:6, type:'dynamic_text', x:44, y:25, binding:{source:'config',key:'game_status',format:null}, font:'four_by_six', r:150,g:150,b:150, xAnchor:null,yAnchor:null,minWidth:0, label:'status', conditions:[] }, + ], + dataModel: { + configVars:[ + {key:'home_team', label:'Home Team', type:'string', default:'HOME', description:'Home team abbreviation'}, + {key:'home_score', label:'Home Score', type:'string', default:'0', description:'Home team score'}, + {key:'away_team', label:'Away Team', type:'string', default:'AWAY', description:'Away team abbreviation'}, + {key:'away_score', label:'Away Score', type:'string', default:'0', description:'Away team score'}, + {key:'game_status',label:'Game Status', type:'string', default:'LIVE', description:'Status label'}, + ], + dataSources:[], computedVars:[], + }, + }, + { + id: 'weather', + label: 'Weather Card', + description: 'Temperature + condition + location', + icon: 'fas fa-cloud-sun', + preset: '128×32', + elements: [ + { id:1, type:'dynamic_text', x:2, y:4, binding:{source:'config',key:'temperature',format:null}, font:'press_start', r:255,g:140,b:40, xAnchor:null,yAnchor:null,minWidth:0, label:'temp', conditions:[] }, + { id:2, type:'divider', x:0, y:16, orientation:'horizontal', r:50,g:50,b:80, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, + { id:3, type:'dynamic_text', x:2, y:20, binding:{source:'config',key:'condition',format:null}, font:'four_by_six', r:180,g:200,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'condition', conditions:[] }, + { id:4, type:'dynamic_text', x:80, y:20, binding:{source:'config',key:'location',format:null}, font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'location', conditions:[] }, + ], + dataModel: { + configVars:[ + {key:'temperature', label:'Temperature', type:'string', default:'72°F', description:'Current temperature'}, + {key:'condition', label:'Condition', type:'string', default:'Sunny', description:'Weather condition'}, + {key:'location', label:'Location', type:'string', default:'My City', description:'City name'}, + ], + dataSources:[], computedVars:[], + }, + }, + { + id: 'crypto', + label: 'Crypto Ticker', + description: 'Asset price + 24h change percentage', + icon: 'fas fa-chart-line', + preset: '128×32', + elements: [ + { id:1, type:'dynamic_text', x:2, y:2, binding:{source:'config',key:'ticker',format:null}, font:'press_start', r:255,g:165,b:0, xAnchor:null,yAnchor:null,minWidth:0, label:'ticker', conditions:[] }, + { id:2, type:'dynamic_text', x:2, y:16, binding:{source:'config',key:'price',format:null}, font:'press_start', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'price', conditions:[] }, + { id:3, type:'dynamic_text', x:88, y:16, binding:{source:'config',key:'change_pct',format:null}, font:'four_by_six', r:100,g:255,b:100, xAnchor:null,yAnchor:null,minWidth:0, label:'change', conditions:[] }, + ], + dataModel: { + configVars:[ + {key:'ticker', label:'Ticker', type:'string', default:'BTC', description:'Asset symbol'}, + {key:'price', label:'Price', type:'string', default:'$0.00', description:'Asset price'}, + {key:'change_pct', label:'Change %', type:'string', default:'+0.00%', description:'24h change'}, + ], + dataSources:[], computedVars:[], + }, + }, + { + id: 'system', + label: 'System Monitor', + description: 'CPU, memory, and IP address', + icon: 'fas fa-server', + preset: '128×32', + elements: [ + { id:1, type:'text', x:2, y:2, text:'CPU', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, + { id:2, type:'dynamic_text', x:22, y:2, binding:{source:'config',key:'cpu_usage',format:null}, font:'four_by_six', r:100,g:255,b:100, xAnchor:null,yAnchor:null,minWidth:0, label:'cpu', conditions:[] }, + { id:3, type:'text', x:2, y:12, text:'MEM', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, + { id:4, type:'dynamic_text', x:22, y:12, binding:{source:'config',key:'mem_usage',format:null}, font:'four_by_six', r:100,g:180,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'mem', conditions:[] }, + { id:5, type:'text', x:2, y:22, text:'IP', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, + { id:6, type:'dynamic_text', x:14, y:22, binding:{source:'config',key:'ip_address',format:null}, font:'four_by_six', r:255,g:255,b:150, xAnchor:null,yAnchor:null,minWidth:0, label:'ip', conditions:[] }, + ], + dataModel: { + configVars:[ + {key:'cpu_usage', label:'CPU %', type:'string', default:'0%', description:'CPU usage'}, + {key:'mem_usage', label:'Memory %', type:'string', default:'0%', description:'Memory usage'}, + {key:'ip_address', label:'IP Address',type:'string', default:'0.0.0.0', description:'Network IP'}, + ], + dataSources:[], computedVars:[], + }, + }, + { + id: 'split_panel', + label: 'Split Panel', + description: 'Left label + right value, divided', + icon: 'fas fa-columns', + preset: '128×32', + elements: [ + { id:1, type:'dynamic_text', x:2, y:4, binding:{source:'config',key:'left_label',format:null}, font:'four_by_six', r:180,g:180,b:180, xAnchor:null,yAnchor:null,minWidth:0, label:'label', conditions:[] }, + { id:2, type:'divider', x:64, y:0, orientation:'vertical', r:60,g:60,b:60, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, + { id:3, type:'dynamic_text', x:68, y:4, binding:{source:'config',key:'right_value',format:null}, font:'press_start', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'value', conditions:[] }, + ], + dataModel: { + configVars:[ + {key:'left_label', label:'Label', type:'string', default:'LABEL', description:'Left panel label'}, + {key:'right_value', label:'Value', type:'string', default:'--', description:'Right panel value'}, + ], + dataSources:[], computedVars:[], + }, + }, +]; + +// ── LED display color palette ──────────────────────────────────────────────── +const LED_PALETTE = [ + { label: 'White', hex: '#ffffff', r: 255, g: 255, b: 255 }, + { label: 'Red', hex: '#ff2020', r: 255, g: 32, b: 32 }, + { label: 'Green', hex: '#20ff40', r: 32, g: 255, b: 64 }, + { label: 'Blue', hex: '#4060ff', r: 64, g: 96, b: 255 }, + { label: 'Yellow', hex: '#ffee00', r: 255, g: 238, b: 0 }, + { label: 'Orange', hex: '#ff8000', r: 255, g: 128, b: 0 }, + { label: 'Cyan', hex: '#00eeff', r: 0, g: 238, b: 255 }, + { label: 'Magenta', hex: '#ff00cc', r: 255, g: 0, b: 204 }, + { label: 'Amber', hex: '#ffb400', r: 255, g: 180, b: 0 }, + { label: 'Dim white', hex: '#888888', r: 136, g: 136, b: 136 }, + { label: 'Dark gray', hex: '#444444', r: 68, g: 68, b: 68 }, + { label: 'Off/Black', hex: '#000000', r: 0, g: 0, b: 0 }, +]; + +// ── Autosave helpers ───────────────────────────────────────────────────────── +const LS_KEY = 'ledmatrix_composer_draft'; +let _autosaveTimer = null; + +function _debouncedAutosave(payload) { + clearTimeout(_autosaveTimer); + _autosaveTimer = setTimeout(() => { + try { localStorage.setItem(LS_KEY, JSON.stringify(payload)); } catch (_) {} + }, 1500); +} + +// ── Main component ─────────────────────────────────────────────────────────── +function composerApp() { + return { + // ── Plugin metadata ─────────────────────────────────────────────── + metadata: { + id: '', + name: '', + author: '', + version: '1.0.0', + description: '', + category: 'custom', + display_duration: 15, + update_interval: 60, + api_requirements: [], + bgColor: { r: 0, g: 0, b: 0 }, + }, + + // ── Canvas / display preset ─────────────────────────────────────── + SCALE: 4, + MATRIX_W: 128, + MATRIX_H: 32, + currentPreset: '128×32', + + // ── Elements ────────────────────────────────────────────────────── + elements: [], + _nextId: 1, + + // ── Data model ─────────────────────────────────────────────────── + dataModel: { + configVars: [], + dataSources: [], + computedVars: [], + }, + + // ── Undo/redo ───────────────────────────────────────────────────── + _history: [], + _historyIndex: -1, + + // ── Selection & interaction ─────────────────────────────────────── + selectedId: null, + _drag: { active: false }, + _resize: { active: false }, + + // ── Canvas options ──────────────────────────────────────────────── + snapToGrid: false, + snapSize: 4, + + // ── Clipboard ───────────────────────────────────────────────────── + _clipboard: null, + _styleClipboard: null, + + // ── UI state ────────────────────────────────────────────────────── + isDirty: false, + statusMsg: '', + statusType: 'info', + generateStatus: 'idle', + installStatus: 'idle', + showGridOverlay: true, + showGuides: false, + blinkAnimating: false, + _blinkPhase: false, + _blinkTimer: null, + _animTick: 0, + _previewValues: {}, + showHelpModal: false, + _recentColors: [], + showRuler: false, + showConfigVarModal: false, + showOpenModal: false, + showTemplateModal: false, + showCodeModal: false, + _idAutoGenerated: true, + hoverInfo: '', + installedPlugins: [], + loadPluginsStatus: 'idle', + codeFiles: {}, + codeTab: 'manager.py', + loadingCode: false, + newConfigVar: { key: '', label: '', type: 'string', default: '', description: '' }, + + // ── Computed ────────────────────────────────────────────────────── + get selectedElement() { + return this.elements.find(e => e.id === this.selectedId) ?? null; + }, + get canExport() { + return this.metadata.name.trim() && this.metadata.id.trim() && this.elements.length > 0; + }, + get canUndo() { return this._historyIndex > 0; }, + get canRedo() { return this._historyIndex < this._history.length - 1; }, + get templates() { return COMPOSER_TEMPLATES; }, + get displayPresets() { return window.ComposerCanvas.DISPLAY_PRESETS; }, + get palette() { return LED_PALETTE; }, + + // ── Pre-made variable library ───────────────────────────────────── + preMadeVarCategories: [ + { label: 'Display', vars: [ + {key:'title_text', label:'Title', type:'string', default:'My Plugin', description:'Main title'}, + {key:'subtitle', label:'Subtitle', type:'string', default:'', description:'Secondary line'}, + {key:'message', label:'Message', type:'string', default:'Hello!', description:'Dynamic message'}, + ]}, + { label: 'Sports / Score', vars: [ + {key:'home_score', label:'Home Score', type:'string', default:'0', description:'Home team score'}, + {key:'away_score', label:'Away Score', type:'string', default:'0', description:'Away team score'}, + {key:'home_team', label:'Home Team', type:'string', default:'HOME', description:'Home team abbr'}, + {key:'away_team', label:'Away Team', type:'string', default:'AWAY', description:'Away team abbr'}, + {key:'game_status', label:'Game Status', type:'string', default:'LIVE', description:'Status label'}, + {key:'period', label:'Period', type:'string', default:'1st', description:'Period/quarter'}, + {key:'time_left', label:'Time Left', type:'string', default:'15:00',description:'Game clock'}, + ]}, + { label: 'Weather', vars: [ + {key:'temperature', label:'Temperature', type:'string', default:'72°F', description:'Current temperature'}, + {key:'condition', label:'Condition', type:'string', default:'Sunny', description:'Weather condition'}, + {key:'humidity', label:'Humidity', type:'string', default:'50%', description:'Humidity'}, + {key:'location', label:'Location', type:'string', default:'My City',description:'Location name'}, + ]}, + { label: 'System / Stats', vars: [ + {key:'cpu_usage', label:'CPU %', type:'string', default:'0%', description:'CPU usage'}, + {key:'mem_usage', label:'Memory %', type:'string', default:'0%', description:'Memory usage'}, + {key:'ip_address', label:'IP Address',type:'string', default:'0.0.0.0', description:'Network IP'}, + {key:'uptime', label:'Uptime', type:'string', default:'0d 0h', description:'System uptime'}, + ]}, + { label: 'Crypto / Finance', vars: [ + {key:'ticker', label:'Ticker', type:'string', default:'BTC', description:'Asset symbol'}, + {key:'price', label:'Price', type:'string', default:'$0.00', description:'Asset price'}, + {key:'change_pct', label:'Change %', type:'string', default:'+0.00%', description:'24h change'}, + {key:'volume', label:'Volume', type:'string', default:'0', description:'Trading volume'}, + ]}, + ], + + elementTypes: [ + { id: 'text', label: 'Text', icon: 'fas fa-font' }, + { id: 'dynamic_text', label: 'Dynamic Text', icon: 'fas fa-code' }, + { id: 'clock', label: 'Clock', icon: 'fas fa-clock' }, + { id: 'rectangle', label: 'Rectangle', icon: 'fas fa-square' }, + { id: 'rounded_rectangle', label: 'Rounded Rect', icon: 'fas fa-stop' }, + { id: 'ellipse', label: 'Ellipse', icon: 'fas fa-circle' }, + { id: 'arc', label: 'Arc', icon: 'fas fa-circle-notch' }, + { id: 'pixel', label: 'Pixel', icon: 'fas fa-dot-circle' }, + { id: 'line', label: 'Line', icon: 'fas fa-minus' }, + { id: 'divider', label: 'Divider', icon: 'fas fa-grip-lines' }, + { id: 'progress_bar', label: 'Progress Bar', icon: 'fas fa-tasks' }, + { id: 'countdown', label: 'Countdown', icon: 'fas fa-hourglass-half' }, + { id: 'marquee', label: 'Marquee', icon: 'fas fa-arrows-alt-h' }, + { id: 'gauge', label: 'Gauge', icon: 'fas fa-tachometer-alt' }, + { id: 'sparkline', label: 'Sparkline', icon: 'fas fa-chart-bar' }, + { id: 'pips', label: 'Pips / Rating', icon: 'fas fa-star-half-alt' }, + { id: 'section', label: 'Section Label', icon: 'fas fa-tag' }, + ], + + COLOR_THEMES: [ + { label: 'Matrix', colors: [{ r:0,g:255,b:70 }, { r:0,g:180,b:50 }, { r:0,g:100,b:30 }] }, + { label: 'Neon', colors: [{ r:255,g:0,b:200 }, { r:0,g:240,b:255 }, { r:255,g:230,b:0 }] }, + { label: 'Warm', colors: [{ r:255,g:160,b:0 }, { r:255,g:80,b:0 }, { r:255,g:220,b:80 }] }, + { label: 'Arctic', colors: [{ r:100,g:200,b:255},{ r:150,g:230,b:255},{ r:200,g:240,b:255}] }, + { label: 'Retro', colors: [{ r:255,g:140,b:0 }, { r:255,g:60,b:60 }, { r:80,g:200,b:120 }] }, + { label: 'Mono', colors: [{ r:255,g:255,b:255},{ r:170,g:170,b:170},{ r:90,g:90,b:90 }] }, + ], + + // ── Lifecycle ───────────────────────────────────────────────────── + init() { + this.$nextTick(() => { + const canvas = document.getElementById('led-canvas'); + if (canvas) { + window.ComposerCanvas.init(canvas); + window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE); + this._loadFonts().then(() => { + this._tryRestoreDraft(); + this._loadRecentColors(); + this._snapshot(); + this.render(); + }); + } + document.addEventListener('keydown', (e) => this._onKeyDown(e)); + }); + }, + + async _loadFonts() { + try { + const ff = new FontFace('PressStart2P', 'url(/composer/api/fonts/PressStart2P-Regular.ttf)'); + document.fonts.add(await ff.load()); + } catch (e) { + console.warn('PressStart2P load failed:', e); + } + }, + + _loadRecentColors() { + try { + const raw = localStorage.getItem('ledmatrix_composer_recent_colors'); + if (raw) this._recentColors = JSON.parse(raw).slice(0, 8); + } catch (_) {} + }, + + _trackColor(r, g, b) { + this._recentColors = [ + { r, g, b }, + ...this._recentColors.filter(c => !(c.r === r && c.g === g && c.b === b)), + ].slice(0, 8); + try { localStorage.setItem('ledmatrix_composer_recent_colors', JSON.stringify(this._recentColors)); } catch (_) {} + }, + + toggleRuler() { + this.showRuler = !this.showRuler; + this.render(); + }, + + _tryRestoreDraft() { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return; + const draft = JSON.parse(raw); + if (!draft?.composer_version) return; + if (!confirm('Resume your last unsaved session?')) { + localStorage.removeItem(LS_KEY); + return; + } + this._applyState(draft); + this._setStatus('Draft restored from last session', 'info'); + } catch (_) {} + }, + + // ── Undo / Redo ─────────────────────────────────────────────────── + _snapshot() { + const state = JSON.stringify({ + metadata: this.metadata, + elements: this.elements, + dataModel: this.dataModel, + currentPreset: this.currentPreset, + }); + this._history.splice(this._historyIndex + 1); + this._history.push(state); + if (this._history.length > 50) this._history.shift(); + else this._historyIndex++; + _debouncedAutosave(this._buildPayload()); + }, + + undo() { + if (!this.canUndo) return; + this._historyIndex--; + const state = JSON.parse(this._history[this._historyIndex]); + this._applyState(state); + this._setStatus('Undo', 'info'); + this.render(); + }, + + redo() { + if (!this.canRedo) return; + this._historyIndex++; + const state = JSON.parse(this._history[this._historyIndex]); + this._applyState(state); + this._setStatus('Redo', 'info'); + this.render(); + }, + + _applyState(state) { + if (state.metadata) this.metadata = state.metadata; + if (state.elements) this.elements = state.elements.map(el => ({ + xAnchor: null, yAnchor: null, minWidth: 0, conditions: [], + ...el, + id: el.id ?? (this._nextId++), + })); + if (state.dataModel) this.dataModel = state.dataModel; + if (state.currentPreset && state.currentPreset !== this.currentPreset) { + this.changePreset(state.currentPreset, { silent: true }); + } + this._nextId = Math.max(...this.elements.map(e => e.id + 1), 1); + this.selectedId = null; + this.isDirty = true; + }, + + // ── Display preset ──────────────────────────────────────────────── + changePreset(presetLabel, opts = {}) { + const preset = window.ComposerCanvas.DISPLAY_PRESETS.find(p => p.label === presetLabel); + if (!preset) return; + this.currentPreset = presetLabel; + this.MATRIX_W = preset.w; + this.MATRIX_H = preset.h; + this.SCALE = preset.w <= 64 ? 6 : preset.w <= 128 ? 4 : 2; + const canvas = document.getElementById('led-canvas'); + if (canvas) { + window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE); + canvas.style.width = (this.MATRIX_W * this.SCALE) + 'px'; + canvas.style.height = (this.MATRIX_H * this.SCALE) + 'px'; + } + if (!opts.silent) this.render(); + }, + + // ── Rendering ───────────────────────────────────────────────────── + render(opts = {}) { + window.ComposerCanvas.render( + this.elements, this.selectedId, this.MATRIX_W, this.MATRIX_H, this.SCALE, + { bgColor: this.metadata.bgColor, showGuides: this.showGuides, showRuler: this.showRuler, blinkOff: this.blinkAnimating && this._blinkPhase, animTick: this.blinkAnimating ? this._animTick : null, previewValues: this._previewValues, ...opts }, + ); + }, + + toggleGrid() { + this.showGridOverlay = !this.showGridOverlay; + window.ComposerCanvas.setGrid(this.showGridOverlay); + this.render(); + }, + + toggleGuides() { + this.showGuides = !this.showGuides; + this.render(); + }, + + toggleBlinkPreview() { + this.blinkAnimating = !this.blinkAnimating; + if (this.blinkAnimating) { + this._blinkPhase = false; + this._animTick = 0; + let blinkTick = 0; + this._blinkTimer = setInterval(() => { + this._animTick++; + blinkTick++; + if (blinkTick >= 3) { this._blinkPhase = !this._blinkPhase; blinkTick = 0; } + this.render(); + }, 200); + } else { + clearInterval(this._blinkTimer); + this._blinkTimer = null; + this._blinkPhase = false; + this._animTick = 0; + this.render(); + } + }, + + downloadPng() { + const canvas = document.getElementById('led-canvas'); + if (!canvas) return; + const link = document.createElement('a'); + link.download = (this.metadata.id || 'composer-preview') + '.png'; + link.href = canvas.toDataURL('image/png'); + link.click(); + }, + + exportDesign() { + const payload = this._buildPayload(); + const json = JSON.stringify(payload, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.download = (this.metadata.id || 'composer-design') + '.composer.json'; + link.href = url; + link.click(); + URL.revokeObjectURL(url); + }, + + importDesign() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,.composer.json'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const data = JSON.parse(ev.target.result); + if (!data.composer_version) throw new Error('Not a composer file'); + if (this.isDirty && !confirm('Replace current design?')) return; + this._applyState({ metadata: data.metadata, elements: data.elements, dataModel: data.dataModel }); + this.isDirty = false; + this._setStatus('Design loaded', 'success'); + this.render(); + } catch (err) { + this._setStatus('Failed to load: ' + err.message, 'error'); + } + }; + reader.readAsText(file); + }; + input.click(); + }, + + // ── Zoom controls ───────────────────────────────────────────────── + zoomIn() { + if (this.SCALE >= 8) return; + this.SCALE++; + this._applyScale(); + }, + zoomOut() { + if (this.SCALE <= 1) return; + this.SCALE--; + this._applyScale(); + }, + _applyScale() { + const canvas = document.getElementById('led-canvas'); + if (!canvas) return; + window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE); + canvas.style.width = (this.MATRIX_W * this.SCALE) + 'px'; + canvas.style.height = (this.MATRIX_H * this.SCALE) + 'px'; + this.render(); + }, + setCustomSize() { + const input = prompt( + 'Enter canvas size as WxH (e.g. 96×48, 192×64):', + `${this.MATRIX_W}×${this.MATRIX_H}` + ); + if (!input) return; + const m = input.match(/(\d+)[×xX*,\s]+(\d+)/); + if (!m) { this._setStatus('Invalid size — use WxH format', 'error'); return; } + const w = Math.max(8, Math.min(512, parseInt(m[1]))); + const h = Math.max(8, Math.min(256, parseInt(m[2]))); + this.MATRIX_W = w; + this.MATRIX_H = h; + this.currentPreset = `${w}×${h}`; + this.SCALE = w <= 64 ? 6 : w <= 128 ? 4 : 2; + this._applyScale(); + this._setStatus(`Canvas set to ${w}×${h}`, 'info'); + }, + zoomFit() { + // Fit canvas to center column: subtract left (~224px) + right (~288px) panels + margins + const avW = Math.max(80, window.innerWidth - 560); + // Subtract header (44) + toolbar (~40) + layers (~180) + status (28) + padding + const avH = Math.max(40, window.innerHeight - 340); + const fitW = Math.floor(avW / this.MATRIX_W); + const fitH = Math.floor(avH / this.MATRIX_H); + this.SCALE = Math.max(1, Math.min(8, Math.min(fitW, fitH))); + this._applyScale(); + }, + + onBgColorChange(hex) { + this.metadata.bgColor = { + r: parseInt(hex.slice(1, 3), 16), + g: parseInt(hex.slice(3, 5), 16), + b: parseInt(hex.slice(5, 7), 16), + }; + this.render(); + }, + + // ── Canvas events ───────────────────────────────────────────────── + onCanvasMouseDown(event) { + const { lx, ly } = this._canvasToLed(event); + + // Priority 1: resize handle on selected rectangle (skip if locked) + if (this.selectedElement?.type === 'rectangle' && !this.selectedElement.locked) { + const handle = window.ComposerCanvas.getResizeHandle( + this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H + ); + if (handle) { + const el = this.selectedElement; + this._resize = { + active: true, elemId: el.id, handle, + startMX: event.clientX, startMY: event.clientY, + startW: el.width, startH: el.height, + startX: el.x, startY: el.y, + }; + return; + } + } + + // Priority 2: element hit test (reverse = top layer first) + const hit = [...this.elements].reverse().find( + el => window.ComposerCanvas.hitTest(el, lx, ly, this.MATRIX_W, this.MATRIX_H) + ); + + if (hit) { + this.selectedId = hit.id; + if (!hit.locked) { + const stored = this._getStoredPos(hit); + this._drag = { + active: true, elemId: hit.id, + startMX: event.clientX, startMY: event.clientY, + startEX: stored.x, startEY: stored.y, + }; + } + } else { + this.selectedId = null; + } + this.render(); + }, + + onCanvasMouseMove(event) { + const { lx, ly } = this._canvasToLed(event); + this.hoverInfo = `${lx}, ${ly}`; + + // Resize + if (this._resize.active) { + const dx = Math.round((event.clientX - this._resize.startMX) / this.SCALE); + const dy = Math.round((event.clientY - this._resize.startMY) / this.SCALE); + const el = this.elements.find(e => e.id === this._resize.elemId); + if (!el) return; + const h = this._resize.handle; + if (h.includes('e')) el.width = Math.max(1, this._resize.startW + dx); + if (h.includes('s')) el.height = Math.max(1, this._resize.startH + dy); + if (h.includes('w')) { + const nw = Math.max(1, this._resize.startW - dx); + el.x = this._resize.startX + (this._resize.startW - nw); + el.width = nw; + } + if (h.includes('n')) { + const nh = Math.max(1, this._resize.startH - dy); + el.y = this._resize.startY + (this._resize.startH - nh); + el.height = nh; + } + this.isDirty = true; + this.render({ showTooltip: true }); + + // Cursor + const canvas = document.getElementById('led-canvas'); + if (canvas) canvas.style.cursor = window.ComposerCanvas.getCursorForHandle(h); + return; + } + + // Move drag + if (this._drag.active) { + const dx = Math.round((event.clientX - this._drag.startMX) / this.SCALE); + const dy = Math.round((event.clientY - this._drag.startMY) / this.SCALE); + const el = this.elements.find(e => e.id === this._drag.elemId); + if (!el) return; + + if (el.type === 'line') { + const newX = Math.max(0, Math.min(this.MATRIX_W - 1, this._drag.startEX + dx)); + const newY = Math.max(0, Math.min(this.MATRIX_H - 1, this._drag.startEY + dy)); + const offX = newX - el.x0, offY = newY - el.y0; + el.x0 = newX; el.y0 = newY; + el.x1 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x1 + offX)); + el.y1 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y1 + offY)); + this._drag.startEX = el.x0; this._drag.startEY = el.y0; + this._drag.startMX = event.clientX; this._drag.startMY = event.clientY; + } else { + // Store anchor-relative offset + const newStored = { + x: this._drag.startEX + dx, + y: this._drag.startEY + dy, + }; + this._setStoredPos(el, newStored); + } + this.isDirty = true; + this.render({ showTooltip: true }); + return; + } + + // Cursor hints when hovering + const canvas = document.getElementById('led-canvas'); + if (canvas) { + let cursor = 'crosshair'; + if (this.selectedElement?.type === 'rectangle') { + const handle = window.ComposerCanvas.getResizeHandle( + this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H + ); + if (handle) cursor = window.ComposerCanvas.getCursorForHandle(handle); + } + if (cursor === 'crosshair') { + const hit = [...this.elements].reverse().find( + el => window.ComposerCanvas.hitTest(el, lx, ly, this.MATRIX_W, this.MATRIX_H) + ); + if (hit) cursor = 'move'; + } + canvas.style.cursor = cursor; + } + }, + + onCanvasMouseUp() { + if (this._resize.active || this._drag.active) { + this._resize = { active: false }; + this._drag = { active: false }; + this._snapshot(); + } + const canvas = document.getElementById('led-canvas'); + if (canvas) canvas.style.cursor = 'crosshair'; + }, + + onCanvasMouseLeave() { + this.hoverInfo = ''; + this.onCanvasMouseUp(); + }, + + _canvasToLed(event) { + const rect = event.target.getBoundingClientRect(); + const lx = Math.max(0, Math.min(this.MATRIX_W - 1, Math.floor((event.clientX - rect.left) / this.SCALE))); + const ly = Math.max(0, Math.min(this.MATRIX_H - 1, Math.floor((event.clientY - rect.top) / this.SCALE))); + return { lx: this._snap(lx), ly: this._snap(ly) }; + }, + + _snap(v) { + if (!this.snapToGrid || this.snapSize < 2) return v; + return Math.round(v / this.snapSize) * this.snapSize; + }, + + // Anchor-aware position storage: x/y stored as offset from anchor + _getStoredPos(el) { + return { x: el.x ?? el.x0 ?? 0, y: el.y ?? el.y0 ?? 0 }; + }, + + _setStoredPos(el, { x, y }) { + if (this.snapToGrid && this.snapSize > 0) { + x = Math.round(x / this.snapSize) * this.snapSize; + y = Math.round(y / this.snapSize) * this.snapSize; + } + const clampX = v => Math.max(-this.MATRIX_W, Math.min(this.MATRIX_W * 2, v)); + const clampY = v => Math.max(-this.MATRIX_H, Math.min(this.MATRIX_H * 2, v)); + if (el.type === 'line') { + el.x0 = clampX(x); el.y0 = clampY(y); + } else { + el.x = clampX(x); el.y = clampY(y); + } + }, + + // ── Alignment ───────────────────────────────────────────────────── + _alignElement(el, axis, mode) { + const bb = window.ComposerCanvas.getBoundingBox(el, this.MATRIX_W, this.MATRIX_H); + if (!bb) return; + if (axis === 'x') { + const newX = mode === 'start' ? 0 + : mode === 'center' ? Math.round((this.MATRIX_W - bb.w) / 2) + : this.MATRIX_W - bb.w; + // Clear x-anchor so stored x IS the absolute position + if ('xAnchor' in el) el.xAnchor = null; + el.x = Math.round(newX); + if (el.type === 'line') el.x0 = Math.round(newX); + } else { + const newY = mode === 'start' ? 0 + : mode === 'center' ? Math.round((this.MATRIX_H - bb.h) / 2) + : this.MATRIX_H - bb.h; + if ('yAnchor' in el) el.yAnchor = null; + el.y = Math.round(newY); + if (el.type === 'line') el.y0 = Math.round(newY); + } + this._snapshot(); + this.isDirty = true; + this.render(); + }, + alignLeft() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'start'); }, + alignHCenter() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'center'); }, + alignRight() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'end'); }, + alignTop() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'start'); }, + alignVCenter() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'center'); }, + alignBottom() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'end'); }, + + // ── Keyboard shortcuts ──────────────────────────────────────────── + _onKeyDown(e) { + const tag = document.activeElement?.tagName; + const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; + + // Ctrl/Cmd combos work everywhere + if (e.ctrlKey || e.metaKey) { + if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); return; } + if ((e.key === 'y') || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); this.redo(); return; } + if (e.key === 'd' && this.selectedId) { e.preventDefault(); this.duplicateElement(this.selectedId); return; } + if (e.key === 'c' && this.selectedId && !e.shiftKey) { e.preventDefault(); this.copyElement(); return; } + if (e.key === 'c' && this.selectedId && e.shiftKey) { e.preventDefault(); this.copyStyle(); return; } + if (e.key === 'v' && this._clipboard && !e.shiftKey) { e.preventDefault(); this.pasteElement(); return; } + if (e.key === 'v' && this._styleClipboard && e.shiftKey) { e.preventDefault(); this.pasteStyle(); return; } + if (e.key === 'a') { e.preventDefault(); if (this.elements.length) { this.selectedId = this.elements[0].id; this.render(); } return; } + } + + // Tab cycles through elements regardless of input focus + if (e.key === 'Tab' && this.elements.length) { + e.preventDefault(); + const idx = this.elements.findIndex(el => el.id === this.selectedId); + const next = e.shiftKey + ? (idx <= 0 ? this.elements.length - 1 : idx - 1) + : (idx >= this.elements.length - 1 ? 0 : idx + 1); + this.selectedId = this.elements[next].id; + this.render(); + return; + } + + if (inInput) return; + + const dist = e.shiftKey ? 5 : (this.snapToGrid && this.snapSize >= 2 ? this.snapSize : 1); + if (e.key === 'ArrowLeft') { e.preventDefault(); this.nudge(-dist, 0); } + if (e.key === 'ArrowRight') { e.preventDefault(); this.nudge(dist, 0); } + if (e.key === 'ArrowUp') { e.preventDefault(); this.nudge(0, -dist); } + if (e.key === 'ArrowDown') { e.preventDefault(); this.nudge(0, dist); } + if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedId) { + e.preventDefault(); + this.removeElement(this.selectedId); + } + if (e.key === 'Escape') { + if (this.showHelpModal) { this.showHelpModal = false; return; } + this.selectedId = null; this.render(); + } + if (e.key === '?') { this.showHelpModal = !this.showHelpModal; } + if (e.key === 'g' || e.key === 'G') { this.snapToGrid = !this.snapToGrid; } + if (e.key === ']' && this.selectedId) { this.bringToFront(this.selectedId); } + if (e.key === '[' && this.selectedId) { this.sendToBack(this.selectedId); } + }, + + nudge(dx, dy) { + const el = this.selectedElement; + if (!el) return; + if (el.locked) { this._setStatus('Unlock element to move it', 'info'); return; } + if (el.type === 'line') { + el.x0 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x0 + dx)); + el.y0 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y0 + dy)); + el.x1 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x1 + dx)); + el.y1 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y1 + dy)); + } else if (el.type === 'divider') { + if (el.orientation === 'horizontal') el.y = Math.max(0, Math.min(this.MATRIX_H - 1, (el.y ?? 0) + dy)); + else el.x = Math.max(0, Math.min(this.MATRIX_W - 1, (el.x ?? 0) + dx)); + } else { + el.x = (el.x ?? 0) + dx; + el.y = (el.y ?? 0) + dy; + } + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + // ── Element management ──────────────────────────────────────────── + addElement(type) { + const defaults = window.ComposerCanvas.ELEMENT_DEFAULTS[type]; + if (!defaults) return; + const el = { + id: this._nextId++, + type, + label: '', + conditions: [], + xAnchor: null, yAnchor: null, minWidth: 0, + x: Math.floor(this.MATRIX_W / 4), + y: Math.floor(this.MATRIX_H / 2) - 4, + ...structuredClone(defaults), + }; + if (type === 'line') { + const cx = Math.floor(this.MATRIX_W / 4), cy = Math.floor(this.MATRIX_H / 2); + el.x0 = cx; el.y0 = cy; + el.x1 = Math.min(this.MATRIX_W - 1, cx + 30); el.y1 = cy; + el.x = cx; el.y = cy; + } + this.elements.push(el); + this.selectedId = el.id; + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + duplicateElement(id) { + const el = this.elements.find(e => e.id === id); + if (!el) return; + const copy = { + ...structuredClone(el), + id: this._nextId++, + label: el.label ? el.label + ' copy' : '', + }; + // Offset slightly so it's visually distinct + if (copy.type === 'line') { copy.x0 += 4; copy.y0 += 4; copy.x1 += 4; copy.y1 += 4; } + else { copy.x = (copy.x ?? 0) + 4; copy.y = (copy.y ?? 0) + 4; } + + const idx = this.elements.findIndex(e => e.id === id); + this.elements.splice(idx + 1, 0, copy); + this.selectedId = copy.id; + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + copyElement() { + if (!this.selectedElement) return; + this._clipboard = structuredClone(this.selectedElement); + this._setStatus(`Copied: ${this.selectedElement.label || this.selectedElement.type}`, 'info'); + }, + + copyStyle() { + const el = this.selectedElement; + if (!el) return; + this._styleClipboard = { r: el.r, g: el.g, b: el.b, font: el.font }; + this._setStatus('Style copied — select another element and paste style', 'info'); + }, + + pasteStyle() { + const el = this.selectedElement; + if (!el || !this._styleClipboard) return; + const { r, g, b, font } = this._styleClipboard; + if (r !== undefined) { el.r = r; el.g = g; el.b = b; } + if (font !== undefined && el.font !== undefined) el.font = font; + this.isDirty = true; + this._snapshot(); + this.render(); + this._setStatus('Style pasted', 'success'); + }, + + pasteElement() { + if (!this._clipboard) return; + const copy = structuredClone(this._clipboard); + copy.id = this._nextId++; + if (copy.type === 'line') { copy.x0 += 6; copy.y0 += 6; copy.x1 += 6; copy.y1 += 6; } + else { copy.x = (copy.x ?? 0) + 6; copy.y = (copy.y ?? 0) + 6; } + this.elements.push(copy); + this.selectedId = copy.id; + this.isDirty = true; + this._snapshot(); + this.render(); + this._setStatus('Pasted element', 'success'); + }, + + removeElement(id) { + this.elements = this.elements.filter(e => e.id !== id); + if (this.selectedId === id) this.selectedId = null; + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + moveElementUp(id) { + const idx = this.elements.findIndex(e => e.id === id); + if (idx < this.elements.length - 1) { + [this.elements[idx], this.elements[idx + 1]] = [this.elements[idx + 1], this.elements[idx]]; + this.isDirty = true; + this._snapshot(); + this.render(); + } + }, + + moveElementDown(id) { + const idx = this.elements.findIndex(e => e.id === id); + if (idx > 0) { + [this.elements[idx], this.elements[idx - 1]] = [this.elements[idx - 1], this.elements[idx]]; + this.isDirty = true; + this._snapshot(); + this.render(); + } + }, + + bringToFront(id) { + const idx = this.elements.findIndex(e => e.id === id); + if (idx < this.elements.length - 1) { + this.elements.push(this.elements.splice(idx, 1)[0]); + this.isDirty = true; + this._snapshot(); + this.render(); + } + }, + + sendToBack(id) { + const idx = this.elements.findIndex(e => e.id === id); + if (idx > 0) { + this.elements.unshift(this.elements.splice(idx, 1)[0]); + this.isDirty = true; + this._snapshot(); + this.render(); + } + }, + + clearAll() { + if (this.elements.length === 0) return; + if (!confirm('Remove all elements from the canvas?')) return; + this.elements = []; + this.selectedId = null; + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + // ── Alignment ───────────────────────────────────────────────────── + alignElement(dir) { + const el = this.selectedElement; + if (!el) return; + const bb = window.ComposerCanvas.getBoundingBox(el, this.MATRIX_W, this.MATRIX_H); + switch (dir) { + case 'left': el.x = 0; break; + case 'center': el.x = Math.round((this.MATRIX_W - bb.w) / 2); break; + case 'right': el.x = this.MATRIX_W - bb.w; break; + case 'top': el.y = 0; break; + case 'middle': el.y = Math.round((this.MATRIX_H - bb.h) / 2); break; + case 'bottom': el.y = this.MATRIX_H - bb.h; break; + } + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + // ── Templates ───────────────────────────────────────────────────── + loadTemplate(tmpl) { + if (this.isDirty && this.elements.length > 0) { + if (!confirm('Load template? Your current canvas will be replaced.')) return; + } + this.elements = tmpl.elements.map((el, i) => ({ + xAnchor: null, yAnchor: null, minWidth: 0, conditions: [], + ...structuredClone(el), + id: i + 1, + })); + this._nextId = this.elements.length + 1; + this.dataModel = structuredClone(tmpl.dataModel); + if (tmpl.preset) this.changePreset(tmpl.preset, { silent: true }); + this.selectedId = null; + this.showTemplateModal = false; + this.isDirty = true; + this._snapshot(); + this.render(); + this._setStatus(`Loaded template: ${tmpl.label}`, 'success'); + }, + + // ── Metadata helpers ────────────────────────────────────────────── + onNameInput() { + if (this._idAutoGenerated) { + this.metadata.id = this.metadata.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + }, + onIdInput() { this._idAutoGenerated = false; }, + isIdValid() { return /^[a-z][a-z0-9-]{0,62}$/.test(this.metadata.id); }, + + // ── Config variables ────────────────────────────────────────────── + addConfigVar() { + const key = this.newConfigVar.key.trim(); + if (!key) { this._setStatus('Key is required', 'error'); return; } + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { + this._setStatus('Key must be a valid Python identifier', 'error'); return; + } + if (this.dataModel.configVars.find(v => v.key === key)) { + this._setStatus(`"${key}" already exists`, 'error'); return; + } + this.dataModel.configVars.push({ ...this.newConfigVar, key }); + this.newConfigVar = { key:'', label:'', type:'string', default:'', description:'' }; + this.showConfigVarModal = false; + this.isDirty = true; + this._snapshot(); + }, + + removeConfigVar(key) { + const bound = this.elements.filter( + e => e.type === 'dynamic_text' && e.binding?.source === 'config' && e.binding?.key === key + ); + if (bound.length && !confirm(`"${key}" is used by ${bound.length} element(s). Remove anyway?`)) return; + this.dataModel.configVars = this.dataModel.configVars.filter(v => v.key !== key); + this.isDirty = true; + this._snapshot(); + }, + + _isBound(key) { + return this.elements.some( + e => e.type === 'dynamic_text' && e.binding?.source === 'config' && e.binding?.key === key + ); + }, + + addPreMadeVar(varDef) { + if (this.dataModel.configVars.find(v => v.key === varDef.key)) { + this._setStatus(`"${varDef.key}" already added`, 'warning'); return; + } + this.dataModel.configVars.push({ ...varDef }); + this.isDirty = true; + this._snapshot(); + this._setStatus(`Added: ${varDef.key}`, 'success'); + }, + + // ── Open existing plugin ────────────────────────────────────────── + async openPluginModal() { + this.showOpenModal = true; + this.installedPlugins = []; + this.loadPluginsStatus = 'loading'; + try { + const resp = await fetch('/composer/api/plugins'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + this.installedPlugins = await resp.json(); + this.loadPluginsStatus = 'done'; + } catch (e) { + this.loadPluginsStatus = 'error'; + this._setStatus('Could not load plugin list: ' + e.message, 'error'); + } + }, + + async loadPlugin(pluginId) { + this.showOpenModal = false; + try { + const resp = await fetch(`/composer/api/load/${pluginId}`); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || `HTTP ${resp.status}`); + } + const data = await resp.json(); + const state = data.state ?? data; + this._applyState(state); + this._idAutoGenerated = false; + this.isDirty = false; + this._snapshot(); + this._setStatus(`Loaded: ${pluginId}` + (data.source === 'schema_import' ? ' (config vars only)' : ''), 'success'); + this.render(); + } catch (e) { + this._setStatus('Failed to load plugin: ' + e.message, 'error'); + } + }, + + // ── Code preview ────────────────────────────────────────────────── + async showPreview() { + if (!this._validateBeforeExport()) return; + this.showCodeModal = true; + this.loadingCode = true; + this.codeFiles = {}; + try { + const resp = await fetch('/composer/api/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this._buildPayload()), + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.message || `HTTP ${resp.status}`); + this.codeFiles = data.files; + this.codeTab = 'manager.py'; + } catch (e) { + this._setStatus('Preview failed: ' + e.message, 'error'); + this.showCodeModal = false; + } finally { + this.loadingCode = false; + } + }, + + // ── Color helpers ───────────────────────────────────────────────── + rgbToHex(r, g, b) { + return '#' + [r, g, b].map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join(''); + }, + + onColorChange(el, hexField, hex) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + if (hexField === 'fill') { el.fillR = r; el.fillG = g; el.fillB = b; } + else if (hexField === 'out') { el.outR = r; el.outG = g; el.outB = b; } + else if (hexField === 'bg') { el.bgR = r; el.bgG = g; el.bgB = b; } + else if (hexField === 'track') { el.trackR = r; el.trackG = g; el.trackB = b; } + else if (hexField === 'empty') { el.emptyR = r; el.emptyG = g; el.emptyB = b; } + else { el.r = r; el.g = g; el.b = b; } + this._trackColor(r, g, b); + this.render(); + }, + + applyPaletteColor(el, hexField, swatch) { + const { r, g, b } = swatch; + if (hexField === 'fill') { el.fillR = r; el.fillG = g; el.fillB = b; } + else if (hexField === 'out') { el.outR = r; el.outG = g; el.outB = b; } + else if (hexField === 'bg') { el.bgR = r; el.bgG = g; el.bgB = b; } + else if (hexField === 'track') { el.trackR = r; el.trackG = g; el.trackB = b; } + else if (hexField === 'empty') { el.emptyR = r; el.emptyG = g; el.emptyB = b; } + else { el.r = r; el.g = g; el.b = b; } + this._trackColor(r, g, b); + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + // ── Export & Install ────────────────────────────────────────────── + _buildPayload() { + return { + composer_version: '1.0', + metadata: this.metadata, + elements: this.elements, + dataModel: this.dataModel, + preset: this.currentPreset, + }; + }, + + _validateBeforeExport() { + if (!this.metadata.name.trim()) { this._setStatus('Plugin name is required', 'error'); return false; } + if (!this.isIdValid()) { this._setStatus('Plugin ID is invalid', 'error'); return false; } + if (!this.metadata.author.trim()) { this._setStatus('Author is required', 'error'); return false; } + if (this.elements.length === 0) { this._setStatus('Add at least one element', 'error'); return false; } + const unbound = this.elements.filter( + e => (e.type === 'dynamic_text' || e.type === 'progress_bar') && !e.binding?.key + ); + if (unbound.length) { this._setStatus(`${unbound.length} element(s) have no variable bound`, 'error'); return false; } + return true; + }, + + async generateZip() { + if (!this._validateBeforeExport()) return; + this.generateStatus = 'working'; + try { + const resp = await fetch('/composer/api/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this._buildPayload()), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({ message: 'Server error' })); + throw new Error(err.message || `HTTP ${resp.status}`); + } + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `${this.metadata.id}.zip`; a.click(); + URL.revokeObjectURL(url); + this.generateStatus = 'done'; + this._setStatus('Plugin ZIP downloaded', 'success'); + setTimeout(() => { if (this.generateStatus === 'done') this.generateStatus = 'idle'; }, 4000); + } catch (err) { + this.generateStatus = 'error'; + this._setStatus(err.message, 'error'); + setTimeout(() => { if (this.generateStatus === 'error') this.generateStatus = 'idle'; }, 6000); + } + }, + + async installLocally(force = false) { + if (!this._validateBeforeExport()) return; + this.installStatus = 'working'; + try { + const resp = await fetch('/composer/api/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...this._buildPayload(), _force: force }), + }); + const data = await resp.json().catch(() => ({ message: 'Server error' })); + + if (resp.status === 409 && !force) { + this.installStatus = 'idle'; + if (confirm(`Plugin "${this.metadata.id}" is already installed.\n\nOverwrite it with the current design?`)) { + return this.installLocally(true); + } + return; + } + + if (!resp.ok) throw new Error(data.message || `HTTP ${resp.status}`); + + this.installStatus = 'done'; + this.isDirty = false; + localStorage.removeItem(LS_KEY); + this._setStatus(`"${this.metadata.id}" installed — enable it in the Plugin Manager`, 'success'); + setTimeout(() => { if (this.installStatus === 'done') this.installStatus = 'idle'; }, 6000); + } catch (err) { + this.installStatus = 'error'; + this._setStatus(err.message, 'error'); + setTimeout(() => { if (this.installStatus === 'error') this.installStatus = 'idle'; }, 6000); + } + }, + + // ── Color themes ────────────────────────────────────────────────── + applyColorTheme(theme) { + // Distribute theme colors round-robin across text-type elements + const textTypes = ['text', 'dynamic_text', 'clock', 'countdown', 'marquee', 'gauge']; + let idx = 0; + for (const el of this.elements) { + if (textTypes.includes(el.type)) { + const c = theme.colors[idx % theme.colors.length]; + el.r = c.r; el.g = c.g; el.b = c.b; + idx++; + } + } + this.isDirty = true; + this._snapshot(); + this.render(); + this._setStatus(`Applied theme: ${theme.label}`, 'success'); + }, + + // ── Status ──────────────────────────────────────────────────────── + _setStatus(msg, type = 'info') { + this.statusMsg = msg; + this.statusType = type; + }, + + statusClass() { + return { + info: 'text-gray-500', + success: 'text-green-600', + error: 'text-red-600', + warning: 'text-yellow-600', + }[this.statusType] || 'text-gray-500'; + }, + }; +} diff --git a/web_interface/static/v3/js/composer/composer-canvas.js b/web_interface/static/v3/js/composer/composer-canvas.js new file mode 100644 index 00000000..adc3791d --- /dev/null +++ b/web_interface/static/v3/js/composer/composer-canvas.js @@ -0,0 +1,757 @@ +/** + * ComposerCanvas — stateless LED matrix canvas renderer. + * + * Coordinate system: LED pixels (integers). All drawing multiplies by SCALE. + * PIL draw.text(x,y) is top-left; canvas fillText(x,y) is baseline. + * → Canvas text cy = (actualY + fontSizePx) * SCALE + * + * Anchors: element x/y are offsets from their anchor point: + * xAnchor=null/'left' → x is fixed offset from left + * xAnchor='center' → x is offset from width/2 + * xAnchor='right' → x is offset inward from right edge + * yAnchor follows the same pattern with 'top'/'middle'/'bottom' + * + * Breakpoints: elements with minWidth > currentMatrixW are rendered at 25% opacity. + * + * Resize handles: drawn on selected rectangles; 8 handles (corners + edge mids). + */ +window.ComposerCanvas = (() => { + 'use strict'; + + let _canvas = null; + let _ctx = null; + let _showGrid = true; + + const DISPLAY_PRESETS = [ + { label: '64×32', w: 64, h: 32 }, + { label: '128×32', w: 128, h: 32 }, + { label: '128×64', w: 128, h: 64 }, + { label: '256×32', w: 256, h: 32 }, + { label: '256×64', w: 256, h: 64 }, + ]; + + const FONT_MAP = { + press_start: { family: "'PressStart2P', monospace", sizePx: 8, charW: 8 }, + four_by_six: { family: 'monospace', sizePx: 6, charW: 4 }, + five_by_seven: { family: 'monospace', sizePx: 7, charW: 5 }, + }; + + const ELEMENT_DEFAULTS = { + text: { + text: 'Hello', font: 'press_start', + r: 255, g: 255, b: 255, + text2: '', lineSpacing: 2, textAlign: 'left', + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + dynamic_text: { + binding: { source: 'config', key: '', format: null }, + font: 'press_start', textAlign: 'left', + r: 255, g: 200, b: 100, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + clock: { + format: '%H:%M', font: 'press_start', + r: 100, g: 255, b: 100, + format2: '', lineSpacing: 2, textAlign: 'left', + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + rectangle: { + width: 20, height: 8, + fillR: 0, fillG: 0, fillB: 128, hasFill: true, + outR: 255, outG: 255, outB: 255, hasOutline: true, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + ellipse: { + width: 24, height: 12, + fillR: 0, fillG: 100, fillB: 200, hasFill: true, + outR: 100, outG: 180, outB: 255, hasOutline: true, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + arc: { + width: 24, height: 24, + startAngle: 0, endAngle: 270, lineWidth: 2, + r: 255, g: 200, b: 0, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + pixel: { + r: 255, g: 255, b: 255, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + rounded_rectangle: { + width: 24, height: 10, borderRadius: 3, + fillR: 0, fillG: 80, fillB: 180, hasFill: true, + outR: 120, outG: 180, outB: 255, hasOutline: true, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + line: { + x0: 0, y0: 16, x1: 63, y1: 16, + r: 180, g: 180, b: 180, lineWidth: 1, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + divider: { + orientation: 'horizontal', y: 16, x: 64, + r: 100, g: 100, b: 100, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + progress_bar: { + barWidth: 60, barHeight: 6, + binding: { source: 'config', key: '', format: null }, + r: 80, g: 200, b: 80, + bgR: 30, bgG: 30, bgB: 30, hasBg: true, + outR: 100, outG: 100, outB: 100, hasOutline: true, + previewPct: 65, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + countdown: { + binding: { source: 'config', key: '', format: null }, + countdownFormat: 'dh', + font: 'four_by_six', textAlign: 'left', + r: 255, g: 180, b: 0, + previewText: '42d 3h', + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + marquee: { + text: 'Scrolling text', font: 'press_start', + r: 255, g: 255, b: 255, + scrollSpeed: 1, gap: 16, direction: 'left', + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + section: { + label: 'Section', + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + pips: { + count: 5, filled: 3, pipSize: 4, pipSpacing: 2, + r: 255, g: 200, b: 0, + emptyR: 50, emptyG: 50, emptyB: 50, showEmpty: true, + binding: { source: 'config', key: '', format: null }, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + sparkline: { + width: 40, height: 12, + barCount: 8, barSpacing: 1, + r: 80, g: 200, b: 120, + bgR: 30, bgG: 30, bgB: 30, hasBg: false, + binding: { source: 'config', key: '', format: null }, + previewData: '0.3,0.6,0.4,0.8,0.5,0.9,0.7,0.85', + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + gauge: { + width: 32, height: 32, + startAngle: 135, endAngle: 45, lineWidth: 3, + binding: { source: 'config', key: '', format: null }, + r: 80, g: 220, b: 80, + trackR: 40, trackG: 40, trackB: 40, hasTrack: true, + showLabel: true, font: 'four_by_six', labelR: 200, labelG: 200, labelB: 200, + previewPct: 65, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + }; + + // ── Anchor resolution ──────────────────────────────────────────────── + function resolveAnchor(val, anchor, dim) { + if (!anchor || anchor === 'left' || anchor === 'top') return val; + if (anchor === 'center' || anchor === 'middle') return Math.floor(dim / 2) + val; + if (anchor === 'right' || anchor === 'bottom') return dim - val; + return val; + } + + function computeActualPos(el, matrixW, matrixH) { + const ax = resolveAnchor(el.x ?? el.x0 ?? 0, el.xAnchor, matrixW); + const ay = resolveAnchor(el.y ?? el.y0 ?? 0, el.yAnchor, matrixH); + return { x: ax, y: ay }; + } + + // ── Bounding box (LED pixel space) ────────────────────────────────── + function getBoundingBox(el, matrixW, matrixH) { + const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH); + const finfo = FONT_MAP[el.font] || FONT_MAP.press_start; + + switch (el.type) { + case 'text': { + const t1 = el.text || '', t2 = el.text2 || ''; + const w = Math.max(t1.length, t2.length) * finfo.charW; + const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx; + const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax; + return { x: bx, y: ay, w, h }; + } + case 'dynamic_text': { + const key = el.binding?.key || '?'; + const w = (`{${key}}`).length * finfo.charW; + const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax; + return { x: bx, y: ay, w, h: finfo.sizePx }; + } + case 'clock': { + const t1 = el.format || '%H:%M', t2 = el.format2 || ''; + const w = Math.max(t1.length, t2.length) * finfo.charW; + const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx; + const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax; + return { x: bx, y: ay, w, h }; + } + case 'countdown': { + const pt = el.previewText || '--d --h'; + const w = pt.length * finfo.charW; + const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax; + return { x: bx, y: ay, w, h: finfo.sizePx }; + } + case 'rectangle': + case 'rounded_rectangle': + case 'ellipse': + case 'arc': + return { x: ax, y: ay, w: el.width, h: el.height }; + case 'pixel': + return { x: ax, y: ay, w: 1, h: 1 }; + case 'line': + return { + x: Math.min(el.x0, el.x1), y: Math.min(el.y0, el.y1), + w: Math.max(1, Math.abs(el.x1 - el.x0)), + h: Math.max(1, Math.abs(el.y1 - el.y0)), + }; + case 'divider': + return el.orientation === 'horizontal' + ? { x: 0, y: ay, w: matrixW, h: 1 } + : { x: ax, y: 0, w: 1, h: matrixH }; + case 'progress_bar': + return { x: ax, y: ay, w: el.barWidth ?? 60, h: el.barHeight ?? 6 }; + case 'marquee': { + const mfinfo = FONT_MAP[el.font] || FONT_MAP.press_start; + return { x: 0, y: ay, w: matrixW, h: mfinfo.sizePx }; + } + case 'gauge': + return { x: ax, y: ay, w: el.width ?? 32, h: el.height ?? 32 }; + case 'sparkline': + return { x: ax, y: ay, w: el.width ?? 40, h: el.height ?? 12 }; + case 'pips': { + const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2; + return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps }; + } + case 'section': + return { x: ax, y: ay, w: 0, h: 0 }; + default: + return { x: ax, y: ay, w: 4, h: 4 }; + } + } + + // ── Resize handle support ───────────────────────────────────────────── + // Returns 8 handle points for a rectangle in LED pixel space + function _getRectHandles(el, matrixW, matrixH) { + const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH); + const w = el.width, h = el.height; + const cx = ax + w / 2, cy = ay + h / 2; + return { + nw: { x: ax, y: ay }, + n: { x: cx, y: ay }, + ne: { x: ax + w, y: ay }, + w: { x: ax, y: cy }, + e: { x: ax + w, y: cy }, + sw: { x: ax, y: ay + h }, + s: { x: cx, y: ay + h }, + se: { x: ax + w, y: ay + h }, + }; + } + + // Returns the handle direction under LED-space point (lx, ly), or null + function getResizeHandle(el, lx, ly, matrixW, matrixH) { + if (!['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) return null; + const handles = _getRectHandles(el, matrixW, matrixH); + const PAD = 4; + for (const [dir, pt] of Object.entries(handles)) { + if (Math.abs(lx - pt.x) <= PAD && Math.abs(ly - pt.y) <= PAD) return dir; + } + return null; + } + + const _HANDLE_CURSORS = { + nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize', + w: 'w-resize', e: 'e-resize', + sw: 'sw-resize', s: 's-resize', se: 'se-resize', + }; + function getCursorForHandle(handle) { + return _HANDLE_CURSORS[handle] || 'crosshair'; + } + + // ── Hit test ───────────────────────────────────────────────────────── + function hitTest(el, lx, ly, matrixW, matrixH) { + const PAD = 3; + const bb = getBoundingBox(el, matrixW, matrixH); + return ( + lx >= bb.x - PAD && lx <= bb.x + bb.w + PAD && + ly >= bb.y - PAD && ly <= bb.y + bb.h + PAD + ); + } + + // ── Draw a single element ───────────────────────────────────────────── + function _drawElement(ctx, el, SCALE, matrixW, matrixH, opts = {}) { + const s = SCALE; + const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH); + const belowBreakpoint = el.minWidth > 0 && matrixW < el.minWidth; + const hidden = el.visible === false; + + ctx.save(); + if (hidden) ctx.globalAlpha = 0.12; + else if (belowBreakpoint) ctx.globalAlpha = 0.25; + + // Blink animation: when blinkOff, fully hide blinking elements + if (el.blink) { + if (opts.blinkOff) { ctx.restore(); return; } + ctx.globalAlpha *= 0.55; + } + + // Helper: compute draw X for text alignment + const _textX = (text, finfo) => { + const tw = text.length * finfo.charW * s; + if (el.textAlign === 'center') return ax * s - tw / 2; + if (el.textAlign === 'right') return ax * s - tw; + return ax * s; + }; + + try { + switch (el.type) { + case 'text': + case 'dynamic_text': + case 'clock': { + const finfo = FONT_MAP[el.font] || FONT_MAP.press_start; + const key = el.binding?.key || '?'; + const pv = opts.previewValues?.[key]; + // Substitute {variable} tokens in text using previewValues + const _subVars = str => (str || '').replace(/\{(\w+)\}/g, (_, k) => { + const v = opts.previewValues?.[k]; + return v !== undefined && v !== '' ? String(v) : `{${k}}`; + }); + const displayText = + el.type === 'text' ? _subVars(el.text || '') + : el.type === 'clock' ? (el.format || '%H:%M') + : (pv !== undefined && pv !== '' ? String(pv) : `{${key}}`); + ctx.font = `${finfo.sizePx * s}px ${finfo.family}`; + ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.fillText(displayText, _textX(displayText, finfo), (ay + finfo.sizePx) * s); + // Second line (text and clock) + if (el.type === 'text' && el.text2) { + const t2 = _subVars(el.text2); + const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2); + ctx.fillText(t2, _textX(t2, finfo), (y2 + finfo.sizePx) * s); + } + if (el.type === 'clock' && el.format2) { + const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2); + ctx.fillText(el.format2, _textX(el.format2, finfo), (y2 + finfo.sizePx) * s); + } + break; + } + + case 'countdown': { + const finfo = FONT_MAP[el.font] || FONT_MAP.press_start; + const t = el.previewText || '--d --h'; + ctx.font = `${finfo.sizePx * s}px ${finfo.family}`; + ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.fillText(t, _textX(t, finfo), (ay + finfo.sizePx) * s); + break; + } + + case 'rectangle': { + const rx = ax * s, ry = ay * s; + const rw = el.width * s, rh = el.height * s; + if (el.hasFill) { + ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`; + ctx.fillRect(rx, ry, rw, rh); + } + if (el.hasOutline) { + ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`; + ctx.lineWidth = 1; + ctx.strokeRect(rx, ry, rw, rh); + } + break; + } + + case 'ellipse': { + const cx = (ax + el.width / 2) * s; + const cy = (ay + el.height / 2) * s; + const rx = (el.width / 2) * s; + const ry = (el.height / 2) * s; + ctx.beginPath(); + ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); + if (el.hasFill) { + ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`; + ctx.fill(); + } + if (el.hasOutline) { + ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`; + ctx.lineWidth = 1; + ctx.stroke(); + } + break; + } + + case 'arc': { + const cx = (ax + el.width / 2) * s; + const cy = (ay + el.height / 2) * s; + const rx = (el.width / 2) * s; + const ry = (el.height / 2) * s; + // PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false + const startRad = (el.startAngle ?? 0) * Math.PI / 180; + const endRad = (el.endAngle ?? 270) * Math.PI / 180; + ctx.beginPath(); + ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false); + ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.lineWidth = Math.max(1, el.lineWidth || 2); + ctx.stroke(); + break; + } + + case 'pixel': { + ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.fillRect(ax * s, ay * s, s, s); + break; + } + + case 'rounded_rectangle': { + const rx = ax * s, ry = ay * s; + const rw = el.width * s, rh = el.height * s; + const rad = Math.min((el.borderRadius ?? 3) * s, rw / 2, rh / 2); + ctx.beginPath(); + ctx.roundRect(rx, ry, rw, rh, rad); + if (el.hasFill) { + ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`; + ctx.fill(); + } + if (el.hasOutline) { + ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`; + ctx.lineWidth = 1; + ctx.stroke(); + } + break; + } + + case 'line': { + ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.lineWidth = Math.max(1, el.lineWidth || 1); + ctx.beginPath(); + ctx.moveTo(el.x0 * s, el.y0 * s); + ctx.lineTo(el.x1 * s, el.y1 * s); + ctx.stroke(); + break; + } + + case 'divider': { + const isH = (el.orientation || 'horizontal') === 'horizontal'; + ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.lineWidth = 1; + ctx.beginPath(); + if (isH) { + ctx.moveTo(0, ay * s + 0.5); + ctx.lineTo(_canvas.width, ay * s + 0.5); + } else { + ctx.moveTo(ax * s + 0.5, 0); + ctx.lineTo(ax * s + 0.5, _canvas.height); + } + ctx.stroke(); + break; + } + + case 'pips': { + const pipCount = Math.max(1, el.count ?? 5); + const pvPips = opts.previewValues?.[el.binding?.key]; + const filledN = pvPips !== undefined + ? Math.max(0, Math.min(pipCount, Math.round(parseFloat(pvPips) || 0))) + : Math.max(0, Math.min(pipCount, el.filled ?? 3)); + const ps = Math.max(1, el.pipSize ?? 4); + const pg = Math.max(0, el.pipSpacing ?? 2); + for (let i = 0; i < pipCount; i++) { + const isFilled = i < filledN; + if (!isFilled && !el.showEmpty) continue; + ctx.fillStyle = isFilled + ? `rgb(${el.r},${el.g},${el.b})` + : `rgb(${el.emptyR ?? 50},${el.emptyG ?? 50},${el.emptyB ?? 50})`; + ctx.fillRect((ax + i * (ps + pg)) * s, ay * s, ps * s, ps * s); + } + break; + } + + case 'sparkline': { + const slW = el.width ?? 40, slH = el.height ?? 12; + const count = Math.max(1, el.barCount ?? 8); + const spacing = el.barSpacing ?? 1; + const barW = Math.max(1, Math.floor((slW - spacing * (count - 1)) / count)); + const rawVals = (el.previewData || '').split(',') + .map(v => parseFloat(v.trim())).filter(n => !isNaN(n)); + while (rawVals.length < count) rawVals.push(0); + const maxV = Math.max(...rawVals.slice(0, count), 0.001); + const rx = ax * s, ry = ay * s; + if (el.hasBg) { + ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`; + ctx.fillRect(rx, ry, slW * s, slH * s); + } + ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; + for (let i = 0; i < count; i++) { + const norm = Math.max(0, Math.min(1, rawVals[i] / maxV)); + const barH = Math.max(1, Math.round(slH * norm)); + const bx = rx + (barW + spacing) * i * s; + const by = ry + (slH - barH) * s; + ctx.fillRect(bx, by, barW * s, barH * s); + } + break; + } + + case 'gauge': { + const gw = (el.width ?? 32), gh = (el.height ?? 32); + const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s; + const rx = (gw / 2) * s, ry = (gh / 2) * s; + const lw = Math.max(1, (el.lineWidth ?? 3)); + const startDeg = el.startAngle ?? 135; + const endDeg = el.endAngle ?? 45; + // Arc sweep: from startDeg clockwise to endDeg (PIL convention) + const totalSweep = ((endDeg - startDeg) + 360) % 360 || 360; + const pvGauge = opts.previewValues?.[el.binding?.key]; + const pct = pvGauge !== undefined + ? Math.max(0, Math.min(100, parseFloat(pvGauge) || 0)) / 100 + : Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100; + const fillSweep = totalSweep * pct; + const toRad = deg => (deg - 90) * Math.PI / 180; // canvas 0=top, PIL 0=right → offset -90 + + // Track arc + if (el.hasTrack !== false) { + ctx.beginPath(); + ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false); + ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`; + ctx.lineWidth = lw * s; + ctx.stroke(); + } + // Fill arc + if (pct > 0) { + ctx.beginPath(); + ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false); + ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.lineWidth = lw * s; + ctx.stroke(); + } + // Centre label + if (el.showLabel) { + const gfinfo = FONT_MAP[el.font || 'four_by_six'] || FONT_MAP.four_by_six; + const labelText = Math.round(pct * 100) + '%'; + ctx.font = `${gfinfo.sizePx * s}px ${gfinfo.family}`; + ctx.fillStyle = `rgb(${el.labelR ?? 200},${el.labelG ?? 200},${el.labelB ?? 200})`; + const ltw = ctx.measureText(labelText).width; + ctx.fillText(labelText, cx - ltw / 2, cy + (gfinfo.sizePx * s) / 2); + } + break; + } + + case 'marquee': { + const finfo = FONT_MAP[el.font] || FONT_MAP.press_start; + const text = el.text || 'Scrolling text'; + const tw = text.length * finfo.charW * s; + const gap = (el.gap ?? 16) * s; + const totalW = tw + gap; + const tick = opts.animTick ?? 0; + const speed = (el.scrollSpeed ?? 1) * 2; + const scrolled = (tick * speed) % totalW; + // left: text enters from right; right: text enters from left + const startX = el.direction === 'right' + ? scrolled - tw + : matrixW * s - scrolled; + ctx.font = `${finfo.sizePx * s}px ${finfo.family}`; + ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; + // Clip to canvas width so text doesn't bleed outside + ctx.save(); + ctx.beginPath(); + ctx.rect(0, ay * s - 1, matrixW * s, (finfo.sizePx + 2) * s); + ctx.clip(); + for (let i = -1; i <= 2; i++) { + ctx.fillText(text, startX + i * totalW, (ay + finfo.sizePx) * s); + } + ctx.restore(); + break; + } + + case 'progress_bar': { + const bw = el.barWidth ?? 60, bh = el.barHeight ?? 6; + const pvPb = opts.previewValues?.[el.binding?.key]; + const pct = pvPb !== undefined + ? Math.max(0, Math.min(100, parseFloat(pvPb) || 0)) / 100 + : Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100; + const rx = ax * s, ry = ay * s; + if (el.hasBg) { + ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`; + ctx.fillRect(rx, ry, bw * s, bh * s); + } + const fillW = Math.max(0, Math.round(bw * pct)); + if (fillW > 0) { + ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.fillRect(rx, ry, fillW * s, bh * s); + } + if (el.hasOutline) { + ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`; + ctx.lineWidth = 1; + ctx.strokeRect(rx, ry, bw * s, bh * s); + } + break; + } + } + + if (belowBreakpoint) { + ctx.globalAlpha = 0.6; + const bb = getBoundingBox(el, matrixW, matrixH); + ctx.font = `${Math.max(8, s * 2)}px monospace`; + ctx.fillStyle = '#facc15'; + ctx.fillText(`≥${el.minWidth}px`, bb.x * s, (bb.y + 4) * s); + } + } finally { + ctx.restore(); + } + } + + // ── Selection indicator ────────────────────────────────────────────── + function _drawSelection(ctx, el, SCALE, matrixW, matrixH) { + const bb = getBoundingBox(el, matrixW, matrixH); + const PAD = 2, s = SCALE; + const rx = bb.x * s - PAD, ry = bb.y * s - PAD; + const rw = bb.w * s + PAD * 2, rh = bb.h * s + PAD * 2; + + ctx.save(); + ctx.strokeStyle = '#3b82f6'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 2]); + ctx.strokeRect(rx, ry, rw, rh); + ctx.setLineDash([]); + + if (el.xAnchor || el.yAnchor) { + ctx.font = `${Math.max(7, s)}px sans-serif`; + ctx.fillStyle = '#a78bfa'; + const anchorText = [ + el.xAnchor ? `x:${el.xAnchor[0]}` : '', + el.yAnchor ? `y:${el.yAnchor[0]}` : '', + ].filter(Boolean).join(' '); + if (anchorText) ctx.fillText(anchorText, rx + 1, ry - 2); + } + + // Resize handles: on rect, rounded rect, ellipse + if (['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) { + const handles = _getRectHandles(el, matrixW, matrixH); + const HS = 5; + ctx.fillStyle = 'white'; + ctx.strokeStyle = '#2563eb'; + ctx.lineWidth = 1; + for (const pt of Object.values(handles)) { + const hx = pt.x * s - HS / 2; + const hy = pt.y * s - HS / 2; + ctx.fillRect(hx, hy, HS, HS); + ctx.strokeRect(hx, hy, HS, HS); + } + } else { + // Corner dots for non-rectangle elements + ctx.fillStyle = '#3b82f6'; + const HS = 4; + for (const [hx, hy] of [ + [rx - HS / 2, ry - HS / 2], [rx + rw - HS / 2, ry - HS / 2], + [rx - HS / 2, ry + rh - HS / 2], [rx + rw - HS / 2, ry + rh - HS / 2], + ]) ctx.fillRect(hx, hy, HS, HS); + } + + ctx.restore(); + } + + // ── Dimension tooltip while dragging ───────────────────────────────── + function drawDragTooltip(ctx, el, SCALE, matrixW, matrixH) { + const bb = getBoundingBox(el, matrixW, matrixH); + const label = el.type === 'rectangle' + ? `${el.width}×${el.height}` + : `${bb.x},${bb.y}`; + const s = SCALE; + ctx.save(); + ctx.font = `${Math.max(9, s * 1.5)}px monospace`; + const tw = ctx.measureText(label).width; + const tx = bb.x * s, ty = (bb.y - 2) * s; + ctx.fillStyle = 'rgba(0,0,0,0.7)'; + ctx.fillRect(tx - 2, ty - 10, tw + 4, 12); + ctx.fillStyle = 'white'; + ctx.fillText(label, tx, ty); + ctx.restore(); + } + + // ── Public API ─────────────────────────────────────────────────────── + + function init(canvasEl) { + _canvas = canvasEl; + _ctx = canvasEl.getContext('2d'); + } + + function setGrid(show) { _showGrid = show; } + + function updateCanvasSize(matrixW, matrixH, SCALE) { + if (!_canvas) return; + _canvas.width = matrixW * SCALE; + _canvas.height = matrixH * SCALE; + } + + function render(elements, selectedId, matrixW, matrixH, SCALE, opts = {}) { + if (!_ctx) return; + const cW = matrixW * SCALE, cH = matrixH * SCALE; + + const bg = opts.bgColor; + _ctx.fillStyle = bg ? `rgb(${bg.r},${bg.g},${bg.b})` : '#000'; + _ctx.fillRect(0, 0, cW, cH); + + if (_showGrid) { + _ctx.strokeStyle = 'rgba(255,255,255,0.07)'; + _ctx.lineWidth = 0.5; + for (let x = SCALE; x < cW; x += SCALE) { + _ctx.beginPath(); _ctx.moveTo(x, 0); _ctx.lineTo(x, cH); _ctx.stroke(); + } + for (let y = SCALE; y < cH; y += SCALE) { + _ctx.beginPath(); _ctx.moveTo(0, y); _ctx.lineTo(cW, y); _ctx.stroke(); + } + } + + for (const el of elements) _drawElement(_ctx, el, SCALE, matrixW, matrixH, opts); + + if (opts.showRuler) { + _ctx.save(); + _ctx.fillStyle = 'rgba(255,255,255,0.08)'; + _ctx.fillRect(0, 0, cW, SCALE); // top strip + _ctx.fillRect(0, 0, SCALE, cH); // left strip + _ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + _ctx.fillStyle = 'rgba(255,255,255,0.6)'; + _ctx.font = `${Math.max(5, SCALE - 1)}px monospace`; + const step = SCALE >= 4 ? 8 : 16; + for (let px = 0; px <= matrixW; px += step) { + const cx = px * SCALE; + const major = px % 32 === 0; + _ctx.lineWidth = 0.5; + _ctx.beginPath(); _ctx.moveTo(cx, 0); _ctx.lineTo(cx, major ? SCALE : SCALE * 0.5); _ctx.stroke(); + if (major && px > 0 && px < matrixW - 4) _ctx.fillText(String(px), cx + 1, SCALE - 1); + } + for (let py = 0; py <= matrixH; py += step) { + const cy = py * SCALE; + const major = py % 32 === 0; + _ctx.beginPath(); _ctx.moveTo(0, cy); _ctx.lineTo(major ? SCALE : SCALE * 0.5, cy); _ctx.stroke(); + if (major && py > 0 && py < matrixH - 4) _ctx.fillText(String(py), 1, cy + SCALE - 1); + } + _ctx.restore(); + } + + if (opts.showGuides) { + _ctx.save(); + _ctx.strokeStyle = 'rgba(255,60,60,0.45)'; + _ctx.lineWidth = 1; + _ctx.setLineDash([4, 3]); + const mx = Math.floor(cW / 2) + 0.5; + const my = Math.floor(cH / 2) + 0.5; + _ctx.beginPath(); _ctx.moveTo(mx, 0); _ctx.lineTo(mx, cH); _ctx.stroke(); + _ctx.beginPath(); _ctx.moveTo(0, my); _ctx.lineTo(cW, my); _ctx.stroke(); + _ctx.setLineDash([]); + _ctx.restore(); + } + + const sel = selectedId != null ? elements.find(e => e.id === selectedId) : null; + if (sel) { + _drawSelection(_ctx, sel, SCALE, matrixW, matrixH); + if (opts.showTooltip) drawDragTooltip(_ctx, sel, SCALE, matrixW, matrixH); + } + } + + return { + init, render, setGrid, updateCanvasSize, + hitTest, getBoundingBox, computeActualPos, resolveAnchor, + getResizeHandle, getCursorForHandle, + ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS, + }; +})(); diff --git a/web_interface/templates/v3/composer.html b/web_interface/templates/v3/composer.html new file mode 100644 index 00000000..b6b062a8 --- /dev/null +++ b/web_interface/templates/v3/composer.html @@ -0,0 +1,1852 @@ + + + + + + Plugin Composer — LEDMatrix + + + + + + +
+ + +
+ + + +
+

+ + Plugin Composer + Beta +

+ + +
+ + +
+ +
+ + + + + + + + + + +
+ + + + + + + + + + +
+ + +
+ + +
+
+ + +
+ Plugin Info +
+
+ Name * + +
+
+ + ID * + + + + +
+
+ Author * + +
+
+
+ Duration s + +
+
+ Version + +
+
+
+ Background +
+ + + +
+
+
+ Category + +
+
+ Description + +
+
+
+ + +
+ Add Elements +
+ +
+
+ + +
+ Color Themes +
+ +
+
+ + +
+
+ Config Variables + +
+
+ +

No variables

+
+
+ + +
+ Quick-Add Vars + +
+ +
+ +
+ +
+
+ + +
+ + +
+
+ Display + + +
+ + × + · × + + + + + + +
+ + +
+ +
+ + + + +
+ +
+ +
+ + +
+ + +
+ + +
+
+ Layers + top ↑ +
+
+ + +
+
+ +
+ + +
+
+ + + + + +
+
+ +
+ + +
+ + + unsaved + + +
+ + + SNAP + + + el · + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + diff --git a/web_interface/templates/v3/composer/manager.py.j2 b/web_interface/templates/v3/composer/manager.py.j2 new file mode 100644 index 00000000..5d90e06d --- /dev/null +++ b/web_interface/templates/v3/composer/manager.py.j2 @@ -0,0 +1,298 @@ +""" +{{ plugin_name }} — LEDMatrix Plugin +Generated by LEDMatrix Plugin Composer on {{ generated_date }} + +Extension points: + update() → add HTTP/MQTT data-fetching logic here + _get_display_values() → map fetched data to display strings + display() → add new elements or adapt layout per display size +""" +from src.plugin_system.base_plugin import BasePlugin +{% if has_clock or has_countdown %} +from datetime import datetime +{% endif %} +{% if has_blink %} +import time +{% endif %} +{% if has_text_template %} +from collections import defaultdict +{% endif %} + + +class {{ class_name }}(BasePlugin): + + def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager): + super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) +{% for var in config_vars %} + self.{{ var.key }} = config.get({{ var.key | tojson }}, {{ var.default | tojson }}) +{% endfor %} + # Live data cache — populated by update(); always {} in static layouts + self._data = {} + + def update(self): + """Fetch and refresh display data. + + For dynamic plugins: fetch from APIs/MQTT here and store in self._data. + _get_display_values() will read self._data to produce display strings. + """ + # --- Data sources (add fetch logic here for dynamic plugins) --- + pass + + def _get_display_values(self): + """Map config variables and live data to display-ready strings. + + This is the single extension point for v2 data sources: + add self._data lookups here once update() populates them. + """ + return { +{% for var in config_vars %} + {{ var.key | tojson }}: str(self.{{ var.key }}), +{% endfor %} + } + + def display(self, force_clear=False): + try: +{% if has_text_template %} + values = defaultdict(str, self._get_display_values()) +{% else %} + values = self._get_display_values() +{% endif %} + + if force_clear: + self.display_manager.clear() + + width = self.display_manager.width + height = self.display_manager.height +{% if bg_color %} + + self.display_manager.draw.rectangle([0, 0, width, height], fill={{ bg_color }}) +{% endif %} + + # ── Elements (rendered bottom to top) ────────────────────────── +{% for el in elements %} +{% set p = " " if el.min_width > 0 else " " %} +{% set pi = (p + " ") if el.blink else p %} +{% if el.min_width > 0 %} + if width >= {{ el.min_width }}: # breakpoint: {{ el.min_width }}px+ displays only +{% endif %} +{% if el.blink %} +{{ p }}if int(time.time() * 2) % 2: +{% endif %} + +{% if el.type == 'text' %} +{{ pi }}self.display_manager.draw_text( +{% if el.text_is_template %} +{{ pi }} {{ el.text | tojson }}.format_map(values), +{% else %} +{{ pi }} {{ el.text | tojson }}, +{% endif %} +{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% if el.text2 %} +{{ pi }}self.display_manager.draw_text( +{% if el.text_is_template %} +{{ pi }} {{ el.text2 | tojson }}.format_map(values), +{% else %} +{{ pi }} {{ el.text2 | tojson }}, +{% endif %} +{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% endif %} +{% elif el.type == 'dynamic_text' %} +{% if el.binding_source == 'config' %} +{{ pi }}self.display_manager.draw_text( +{{ pi }} values.get({{ el.binding_key | tojson }}, ''), +{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% endif %} +{% elif el.type == 'clock' %} +{{ pi }}self.display_manager.draw_text( +{{ pi }} datetime.now().strftime({{ el.format | tojson }}), +{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% if el.format2 %} +{{ pi }}self.display_manager.draw_text( +{{ pi }} datetime.now().strftime({{ el.format2 | tojson }}), +{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% endif %} +{% elif el.type == 'countdown' %} +{{ pi }}_cd_target = float(values.get({{ el.binding_key | tojson }}, 0) or 0) +{{ pi }}_cd_secs = max(0.0, _cd_target - datetime.now().timestamp()) +{% if el.countdown_format == 'dhms' %} +{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400) +{{ pi }}_cd_h, _cd_rem = divmod(_cd_rem, 3600) +{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60) +{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}:{_cd_m:02d}:{_cd_s:02d}' +{% elif el.countdown_format == 'hms' %} +{{ pi }}_cd_h, _cd_rem = divmod(int(_cd_secs), 3600) +{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60) +{{ pi }}_cd_str = f'{_cd_h}h {_cd_m:02d}:{_cd_s:02d}' +{% elif el.countdown_format == 'dhm' %} +{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400) +{{ pi }}_cd_h, _cd_m = divmod(_cd_rem // 60, 60) +{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}h {_cd_m:02d}m' +{% else %} +{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400) +{{ pi }}_cd_h = _cd_rem // 3600 +{{ pi }}_cd_str = f'{_cd_d}d {_cd_h}h' +{% endif %} +{{ pi }}self.display_manager.draw_text( +{{ pi }} _cd_str, +{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% elif el.type == 'rectangle' %} +{{ pi }}self.display_manager.draw.rectangle( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], +{{ pi }} fill={{ el.fill_tuple }}, +{{ pi }} outline={{ el.outline_tuple }}, +{{ pi }}) +{% elif el.type == 'arc' %} +{{ pi }}self.display_manager.draw.arc( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], +{{ pi }} start={{ el.start_angle }}, end={{ el.end_angle }}, +{{ pi }} fill={{ el.rgb_tuple }}, +{{ pi }} width={{ el.line_width }}, +{{ pi }}) +{% elif el.type == 'ellipse' %} +{{ pi }}self.display_manager.draw.ellipse( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], +{{ pi }} fill={{ el.fill_tuple }}, +{{ pi }} outline={{ el.outline_tuple }}, +{{ pi }}) +{% elif el.type == 'pixel' %} +{{ pi }}self.display_manager.draw.point( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}], +{{ pi }} fill={{ el.rgb_tuple }}, +{{ pi }}) +{% elif el.type == 'rounded_rectangle' %} +{{ pi }}self.display_manager.draw.rounded_rectangle( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], +{{ pi }} radius={{ el.border_radius }}, +{{ pi }} fill={{ el.fill_tuple }}, +{{ pi }} outline={{ el.outline_tuple }}, +{{ pi }}) +{% elif el.type in ('line', 'divider') %} +{{ pi }}self.display_manager.draw.line( +{{ pi }} [{{ el.x0_expr }}, {{ el.y0_expr }}, {{ el.x1_expr }}, {{ el.y1_expr }}], +{{ pi }} fill={{ el.rgb_tuple }}, +{{ pi }} width={{ el.line_width }}, +{{ pi }}) +{% elif el.type == 'pips' %} +{{ pi }}_pip_filled = max(0, min({{ el.pip_count }}, int(float(values.get({{ el.binding_key | tojson }}, 0) or 0)))) +{{ pi }}for _pip_i in range({{ el.pip_count }}): +{{ pi }} _pip_x = ({{ el.x_expr }}) + _pip_i * ({{ el.pip_size }} + {{ el.pip_spacing }}) +{{ pi }} _pip_color = {{ el.fill_tuple }} if _pip_i < _pip_filled else {{ el.empty_tuple }} +{% if not el.show_empty %} +{{ pi }} if _pip_i >= _pip_filled: +{{ pi }} continue +{% endif %} +{{ pi }} self.display_manager.draw.rectangle( +{{ pi }} [_pip_x, {{ el.y_expr }}, _pip_x + {{ el.pip_size }} - 1, ({{ el.y_expr }}) + {{ el.pip_size }} - 1], +{{ pi }} fill=_pip_color, +{{ pi }} ) +{% elif el.type == 'sparkline' %} +{{ pi }}_sl_raw = str(values.get({{ el.binding_key | tojson }}, '') or '') +{{ pi }}_sl_vals = [float(v.strip()) for v in _sl_raw.split(',') if v.strip()][:{{ el.bar_count }}] +{{ pi }}_sl_vals += [0.0] * max(0, {{ el.bar_count }} - len(_sl_vals)) +{{ pi }}_sl_max = max(_sl_vals) if any(_sl_vals) else 1.0 +{{ pi }}_sl_bw = max(1, ({{ el.bar_width_px }} - {{ el.bar_spacing }} * ({{ el.bar_count }} - 1)) // {{ el.bar_count }}) +{% if el.bg_tuple != 'None' %} +{{ pi }}self.display_manager.draw.rectangle( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, ({{ el.x_expr }}) + {{ el.bar_width_px }}, ({{ el.y_expr }}) + {{ el.bar_height_px }}], +{{ pi }} fill={{ el.bg_tuple }}, +{{ pi }}) +{% endif %} +{{ pi }}for _sl_i, _sl_v in enumerate(_sl_vals): +{{ pi }} _sl_norm = max(0.0, min(1.0, _sl_v / (_sl_max or 1))) +{{ pi }} _sl_bh = max(1, round({{ el.bar_height_px }} * _sl_norm)) +{{ pi }} _sl_bx = ({{ el.x_expr }}) + (_sl_bw + {{ el.bar_spacing }}) * _sl_i +{{ pi }} _sl_by = ({{ el.y_expr }}) + {{ el.bar_height_px }} - _sl_bh +{{ pi }} self.display_manager.draw.rectangle( +{{ pi }} [_sl_bx, _sl_by, _sl_bx + _sl_bw - 1, _sl_by + _sl_bh - 1], +{{ pi }} fill={{ el.fill_tuple }}, +{{ pi }} ) +{% elif el.type == 'gauge' %} +{{ pi }}_gv = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0))) +{{ pi }}_g_total = (({{ el.end_angle }} - {{ el.start_angle }}) % 360) or 360 +{{ pi }}_g_sweep = _g_total * _gv / 100.0 +{% if el.track_tuple != 'None' %} +{{ pi }}self.display_manager.draw.arc( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], +{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_total, +{{ pi }} fill={{ el.track_tuple }}, +{{ pi }} width={{ el.line_width }}, +{{ pi }}) +{% endif %} +{{ pi }}if _g_sweep > 0: +{{ pi }} self.display_manager.draw.arc( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], +{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_sweep, +{{ pi }} fill={{ el.rgb_tuple }}, +{{ pi }} width={{ el.line_width }}, +{{ pi }} ) +{% if el.show_label %} +{{ pi }}_g_cx = ({{ el.x_expr }}) + ({{ el.x2_expr }} - ({{ el.x_expr }})) // 2 +{{ pi }}_g_cy = ({{ el.y_expr }}) + ({{ el.y2_expr }} - ({{ el.y_expr }})) // 2 +{{ pi }}self.display_manager.draw_text( +{{ pi }} f'{int(_gv)}%', +{{ pi }} x=_g_cx, y=_g_cy, +{{ pi }} color={{ el.label_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% endif %} +{% elif el.type == 'marquee' %} +{{ pi }}_{{ el.data_key }}_text = {{ el.text | tojson }} +{{ pi }}_{{ el.data_key }}_tw = len(_{{ el.data_key }}_text) * {{ el.char_w }} +{{ pi }}_{{ el.data_key }}_x = int(self._data.get({{ el.data_key | tojson }}, width)) +{% if el.direction == 'right' %} +{{ pi }}_{{ el.data_key }}_x += {{ el.scroll_speed }} +{{ pi }}if _{{ el.data_key }}_x > width: +{{ pi }} _{{ el.data_key }}_x = -(_{{ el.data_key }}_tw + {{ el.gap }}) +{% else %} +{{ pi }}_{{ el.data_key }}_x -= {{ el.scroll_speed }} +{{ pi }}if _{{ el.data_key }}_x < -(_{{ el.data_key }}_tw + {{ el.gap }}): +{{ pi }} _{{ el.data_key }}_x = width +{% endif %} +{{ pi }}self._data[{{ el.data_key | tojson }}] = _{{ el.data_key }}_x +{{ pi }}self.display_manager.draw_text( +{{ pi }} _{{ el.data_key }}_text, +{{ pi }} x=_{{ el.data_key }}_x, y={{ el.y_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% elif el.type == 'progress_bar' %} +{{ pi }}_pb_x = {{ el.x_expr }} +{{ pi }}_pb_y = {{ el.y_expr }} +{{ pi }}_pb_pct = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0))) / 100.0 +{{ pi }}_pb_fill_w = int({{ el.bar_width }} * _pb_pct) +{{ pi }}self.display_manager.draw.rectangle( +{{ pi }} [_pb_x, _pb_y, _pb_x + {{ el.bar_width }}, _pb_y + {{ el.bar_height }}], +{{ pi }} fill={{ el.bg_tuple }}, +{{ pi }} outline={{ el.outline_tuple }}, +{{ pi }}) +{{ pi }}if _pb_fill_w > 0: +{{ pi }} self.display_manager.draw.rectangle( +{{ pi }} [_pb_x, _pb_y, _pb_x + _pb_fill_w, _pb_y + {{ el.bar_height }}], +{{ pi }} fill={{ el.fill_tuple }}, +{{ pi }} ) +{% endif %} +{% endfor %} + # ── End elements ─────────────────────────────────────────────── + + self.display_manager.update_display() + + except Exception as e: + self.logger.error('Display error: %s', e, exc_info=True) From 47e3021fc3d5c750ccbcf223a7c00096a78e4262 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Tue, 14 Jul 2026 16:31:43 -0400 Subject: [PATCH 02/15] fix: address Codacy findings in the Composer blueprint - Dropped a pointless f-string prefix (no placeholders) on the default plugin description. - Replaced two bare except:pass/continue blocks (manifest.json listing, config_schema.json parsing) with a logged warning before falling through to the same skip-this-entry behavior -- same control flow, now visible in logs instead of silent. Skipped as false positives (verified against actual usage, not fixed): - Jinja2 Environment(autoescape=False) -- this env renders manager.py.j2, a Python source-code generator, never HTML; autoescaping would corrupt generated code. Flagged by a generic XSS rule that assumes all Jinja2 environments render HTML. - "Flask route directly returning a formatted string" on _as_rgb_filter -- that's a Jinja *filter* function, not a Flask route. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- web_interface/blueprints/composer.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py index a6e19374..68642a84 100644 --- a/web_interface/blueprints/composer.py +++ b/web_interface/blueprints/composer.py @@ -509,7 +509,7 @@ def _generate_plugin_files(data: dict) -> dict: 'name': plugin_name, 'version': version, 'author': author, - 'description': metadata.get('description', f'Custom plugin created with LEDMatrix Plugin Composer'), + 'description': metadata.get('description', 'Custom plugin created with LEDMatrix Plugin Composer'), 'category': metadata.get('category', 'custom'), 'tags': ['composer', 'custom'], 'entry_point': 'manager.py', @@ -721,7 +721,8 @@ def list_plugins(): continue try: manifest = json.loads(manifest_path.read_text()) - except Exception: + except Exception as e: + logger.warning("Skipping %s: unreadable manifest.json (%s)", entry.name, e) continue has_state = (entry / '_composer_state.json').exists() results.append({ @@ -808,8 +809,8 @@ def load_plugin(plugin_id): 'default': prop.get('default', ''), 'description': prop.get('description', ''), }) - except Exception: - pass + except Exception as e: + logger.warning("Failed to parse config_schema.json for %s: %s", plugin_id, e) manifest = {} if manifest_path.exists(): From cd7e16e58ec6ebbe2ad30f843a21b63ef10f6db4 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Tue, 14 Jul 2026 17:23:25 -0400 Subject: [PATCH 03/15] fix(security): validate plugin_id before path construction in /api/install CodeQL flagged 16 high-severity "path depends on user-provided value" alerts. Investigated each: - install_locally() (/api/install) built a filesystem path from metadata.id without validating it at that point -- it was only implicitly safe because _generate_plugin_files() validates the same field (re-extracted independently) earlier in the same request. That's a real gap: reorder or change that earlier call and it's an exploitable path traversal / arbitrary file write. Fixed by validating plugin_id directly against _PLUGIN_ID_RE at the point the path is built, matching the pattern already used correctly in validate_id() and load_plugin(). - The other 10 flagged locations (serve_font's allowlist check, validate_id, load_plugin and its downstream reads) were already guarded by an explicit check earlier in the same function -- false positives from CodeQL not modeling those as sanitizers. Also fixed 2 of the 5 "stack trace exposed" warnings that were genuine: install_locally() and load_plugin() returned raw OSError/Exception text to the client in a 500 response; now logged server-side with a generic client-facing message. The other 3 (generate_zip/install_locally/ preview_code returning str(ValueError) from _generate_plugin_files) are deliberate, human-authored validation messages, not exception internals -- left as-is. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- web_interface/blueprints/composer.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py index 68642a84..9b3461f6 100644 --- a/web_interface/blueprints/composer.py +++ b/web_interface/blueprints/composer.py @@ -647,6 +647,13 @@ def install_locally(): return jsonify({'status': 'error', 'message': str(exc)}), 422 plugin_id = data.get('metadata', {}).get('id', '') + # _generate_plugin_files() above already validates metadata.id via this + # same regex before it will return, but that guarantee lives in a + # different function -- re-check here, at the point the path is actually + # built, so this route stays safe on its own if that call is ever + # reordered or changed. + if not _PLUGIN_ID_RE.match(plugin_id): + return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400 target = Path(composer_bp.plugins_dir) / plugin_id force = bool(data.get('_force', False)) @@ -665,7 +672,8 @@ def install_locally(): (target / filename).write_text(content, encoding='utf-8') _save_composer_state(target, data) except OSError as exc: - return jsonify({'status': 'error', 'message': f'Failed to write plugin files: {exc}'}), 500 + logger.error('Failed to write plugin files for %s: %s', plugin_id, exc) + return jsonify({'status': 'error', 'message': 'Failed to write plugin files'}), 500 # Trigger plugin discovery so it shows up in the Plugin Manager immediately if composer_bp.plugin_manager: @@ -778,7 +786,8 @@ def load_plugin(plugin_id): state = json.loads(state_path.read_text()) return jsonify({'status': 'ok', 'source': 'composer', 'state': state}) except Exception as exc: - return jsonify({'status': 'error', 'message': f'Failed to read state: {exc}'}), 500 + logger.error('Failed to read composer state for %s: %s', plugin_id, exc) + return jsonify({'status': 'error', 'message': 'Failed to read state'}), 500 # Partial import from config_schema.json schema_path = plugin_dir / 'config_schema.json' From e499efb1f078e715ad48bdec5c61275e42611e1b Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Fri, 21 Aug 2026 17:18:26 -0400 Subject: [PATCH 04/15] fix(composer): resolve plugin paths at the filesystem boundary CodeQL reports 19 alerts against this PR -- 16 high-severity py/path-injection plus 3 py/stack-trace-exposure -- all in web_interface/blueprints/composer.py, where a request-supplied plugin_id reaches Path(plugins_dir) / plugin_id and the result is created, written to, deleted with shutil.rmtree, and read back. The path-injection alerts are false positives today. _PLUGIN_ID_RE is fully anchored and permits only [a-z][a-z0-9-]{0,62}, so every traversal payload is already rejected; I checked fourteen of them, including ../../etc/passwd, a/../../etc, /etc/passwd and encoded variants, and none gets past it. They are worth fixing anyway. The guarantee lived in a regex several hundred lines from the path building, so relaxing that pattern later -- to allow an underscore, say -- would open a traversal with nothing at the filesystem boundary to catch it. _plugin_dir() now resolves the candidate and refuses anything that is not inside plugins_dir, and all three call sites go through it. That is also the shape static analysis recognises, which is why sixteen alerts landed on code that was already safe. The regex anchor moves from $ to \Z. Python's $ also matches just before a trailing newline, so "myplugin\n" was accepted and would have created a directory whose name ends in one. Not traversal, but not a name anything downstream should have to handle. For the stack-trace exposure: the handlers returned str(exc) for any ValueError out of _generate_plugin_files. The seven raises there are all curated, user-facing validation messages, and they now use a ComposerInputError subclass so they keep reaching the user verbatim. A ValueError from anywhere else -- json, int(), a library -- is logged with a traceback and answered generically, since its text can name internal paths. Verified: 32 tests covering fourteen traversal payloads and twelve malformed ids. The key one re-runs every payload with the id pattern deliberately loosened to allow slashes and dots; removing the containment check fails it with these escaped the base with a loosened regex: [('/etc/passwd', '/etc/passwd'), ('//etc/passwd', '//etc/passwd')] so the boundary is doing real work rather than shadowing the regex. The 5 failures in test_web_api.py are unrelated and pre-existing on this branch -- identical counts with these changes stashed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- test/test_composer_path_containment.py | 91 +++++++++++++++++++++++++ web_interface/blueprints/composer.py | 92 ++++++++++++++++++++------ 2 files changed, 164 insertions(+), 19 deletions(-) create mode 100644 test/test_composer_path_containment.py diff --git a/test/test_composer_path_containment.py b/test/test_composer_path_containment.py new file mode 100644 index 00000000..cc5f067b --- /dev/null +++ b/test/test_composer_path_containment.py @@ -0,0 +1,91 @@ +"""A composer plugin id must never resolve outside the plugins directory. + +CodeQL reported sixteen high-severity py/path-injection alerts against +web_interface/blueprints/composer.py: a request-supplied plugin_id reaching +Path(plugins_dir) / plugin_id, which is then created, written to, deleted +(shutil.rmtree) and read back. + +The id was already validated by an anchored regex, so every traversal payload +was in fact rejected. What was missing was the guarantee living *with* the path +building rather than in a regex several hundred lines away -- loosen that regex +later and the traversal opens silently, with nothing at the filesystem boundary +to catch it. _plugin_dir() closes that, and is the form static analysis can see. +""" +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from web_interface.blueprints import composer as C # noqa: E402 + +#: Anything that has ever been used to climb out of a directory. +TRAVERSAL = [ + "../../etc/passwd", "..", ".", "a/../../etc", "good/../../..", + "/etc/passwd", "//etc/passwd", "a\\..\\..", "a%2f..%2f..", + "....//....//etc", "a/./../../etc", "~", "~root", + "plugin/../../../../../../etc/shadow", +] + +#: Rejected for shape, not traversal -- but rejected all the same. +MALFORMED = ["", "A-upper", "1-leading-digit", "-leading-dash", "has_underscore", + "has space", "has.dot", "a" * 64, "plugin\n", "plugin\n../../etc", + "\n", "plug\x00in"] + + +@pytest.fixture +def plugins_dir(tmp_path, monkeypatch): + base = tmp_path / "plugin-repos" + base.mkdir() + monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False) + return base + + +@pytest.mark.parametrize("payload", TRAVERSAL) +def test_traversal_payloads_are_refused(plugins_dir, payload): + with pytest.raises(C.ComposerInputError): + C._plugin_dir(payload) + + +@pytest.mark.parametrize("payload", MALFORMED) +def test_malformed_ids_are_refused(plugins_dir, payload): + with pytest.raises(C.ComposerInputError): + C._plugin_dir(payload) + + +@pytest.mark.parametrize("payload", ["a", "my-plugin", "x9", "a" * 63]) +def test_valid_ids_resolve_inside_the_base(plugins_dir, payload): + resolved = C._plugin_dir(payload) + assert resolved.parent == plugins_dir.resolve(), ( + f"{payload!r} resolved to {resolved}, outside {plugins_dir}") + + +def test_no_payload_can_escape_even_if_the_regex_is_loosened(plugins_dir, monkeypatch): + """The containment check must stand on its own. + + This is the whole point of resolving at the filesystem boundary: if the id + pattern is ever relaxed, traversal must still be impossible. Replace the + regex with one that permits slashes and dots, then re-run the payloads. + """ + import re + monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z")) + escaped = [] + for payload in TRAVERSAL: + try: + resolved = C._plugin_dir(payload) + except C.ComposerInputError: + continue + base = plugins_dir.resolve() + if resolved != base and base not in resolved.parents: + escaped.append((payload, str(resolved))) + assert not escaped, f"these escaped the base with a loosened regex: {escaped}" + + +def test_a_trailing_newline_is_not_a_valid_id(): + r"""Python's `$` also matches before a trailing newline, so the original + `^...$` accepted "myplugin\n" and would have created a directory whose + name ends in one. \Z does not.""" + assert C._PLUGIN_ID_RE.match("myplugin") is not None + assert C._PLUGIN_ID_RE.match("myplugin\n") is None diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py index 9b3461f6..3eb0002c 100644 --- a/web_interface/blueprints/composer.py +++ b/web_interface/blueprints/composer.py @@ -48,7 +48,11 @@ 'five_by_seven': 7, } -_PLUGIN_ID_RE = re.compile(r'^[a-z][a-z0-9-]{0,62}$') +_PLUGIN_ID_RE = re.compile(r'\A[a-z][a-z0-9-]{0,62}\Z') +#: \Z, not $. Python's $ also matches just before a trailing newline, +#: so '$' would accept "myplugin\\n" and create a directory whose name +#: ends in one. Not traversal, but not a name anything should have to +#: handle either. _PYTHON_IDENT_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$') # ── Jinja2 environment (separate from Flask's; autoescape=False for code gen) ── @@ -431,18 +435,18 @@ def _generate_plugin_files(data: dict) -> dict: plugin_id = metadata.get('id', '').strip() if not _PLUGIN_ID_RE.match(plugin_id): - raise ValueError( + raise ComposerInputError( 'Plugin ID must start with a lowercase letter and contain only ' 'lowercase letters, numbers, and hyphens (max 63 chars).' ) plugin_name = metadata.get('name', '').strip() if not plugin_name: - raise ValueError('Plugin name is required.') + raise ComposerInputError('Plugin name is required.') author = metadata.get('author', '').strip() if not author: - raise ValueError('Author is required.') + raise ComposerInputError('Author is required.') version = metadata.get('version', '1.0.0').strip() @@ -450,7 +454,7 @@ def _generate_plugin_files(data: dict) -> dict: for cv in config_vars: key = cv.get('key', '') if not _PYTHON_IDENT_RE.match(key): - raise ValueError(f'Config variable key "{key}" is not a valid Python identifier.') + raise ComposerInputError(f'Config variable key "{key}" is not a valid Python identifier.') class_name = _to_class_name(plugin_name) # Only consider visible elements for code generation flags @@ -481,7 +485,7 @@ def _generate_plugin_files(data: dict) -> dict: try: tmpl = env.get_template('manager.py.j2') except jinja2.TemplateNotFound: - raise ValueError('Code generation template not found. This is a server configuration issue.') + raise ComposerInputError('Code generation template not found. This is a server configuration issue.') manager_py = tmpl.render( plugin_name=plugin_name, @@ -501,7 +505,7 @@ def _generate_plugin_files(data: dict) -> dict: try: ast.parse(manager_py) except SyntaxError as exc: - raise ValueError(f'Generated code has a syntax error: {exc}') from exc + raise ComposerInputError(f'Generated code has a syntax error: {exc}') from exc # Build manifest manifest = { @@ -533,7 +537,7 @@ def _generate_plugin_files(data: dict) -> dict: errors = list(validator.iter_errors(manifest)) if errors: msgs = '; '.join(e.message for e in errors[:3]) - raise ValueError(f'Manifest validation failed: {msgs}') + raise ComposerInputError(f'Manifest validation failed: {msgs}') # Build config_schema type_map = { @@ -585,6 +589,37 @@ def _generate_plugin_files(data: dict) -> dict: } +class ComposerInputError(ValueError): + """A validation failure whose message is safe to show the caller. + + _generate_plugin_files raises this for input the user can fix. Anything + else reaching the handlers is unexpected, and its text may name internal + paths or library internals, so it is logged and answered generically. + """ + + +def _plugin_dir(plugin_id: str) -> Path: + """Resolve a plugin directory, refusing anything outside plugins_dir. + + _PLUGIN_ID_RE already rejects '/', '.' and '..', so this cannot currently + fail -- every traversal payload is blocked before it gets here. It exists + anyway for two reasons: the guarantee then lives with the path building + rather than in a regex several hundred lines away, so loosening that regex + later cannot silently open a traversal; and it is the form static analysis + recognises, which is why CodeQL reported sixteen path-injection alerts + against code that was already safe. + + Raises ComposerInputError if the id is malformed or escapes the base. + """ + if not _PLUGIN_ID_RE.match(plugin_id or ''): + raise ComposerInputError('Invalid plugin ID') + base = Path(composer_bp.plugins_dir).resolve() + candidate = (base / plugin_id).resolve() + if candidate != base and base not in candidate.parents: + raise ComposerInputError('Invalid plugin ID') + return candidate + + def _save_composer_state(target_dir: Path, payload: dict) -> None: """Persist the raw composer payload alongside the generated plugin files.""" (target_dir / '_composer_state.json').write_text( @@ -618,8 +653,13 @@ def generate_zip(): return jsonify({'status': 'error', 'message': 'No JSON body'}), 400 try: files = _generate_plugin_files(data) - except ValueError as exc: + except ComposerInputError as exc: return jsonify({'status': 'error', 'message': str(exc)}), 422 + except ValueError as exc: + # Not one of ours: the text may name internal paths or library + # internals, so log it and answer generically. + logger.exception('Unexpected error generating plugin files: %s', exc) + return jsonify({'status': 'error', 'message': 'Could not generate plugin files'}), 422 plugin_id = data.get('metadata', {}).get('id', 'plugin') files['_composer_state.json'] = json.dumps(data, indent=2, ensure_ascii=False) @@ -643,8 +683,13 @@ def install_locally(): try: files = _generate_plugin_files(data) - except ValueError as exc: + except ComposerInputError as exc: return jsonify({'status': 'error', 'message': str(exc)}), 422 + except ValueError as exc: + # Not one of ours: the text may name internal paths or library + # internals, so log it and answer generically. + logger.exception('Unexpected error generating plugin files: %s', exc) + return jsonify({'status': 'error', 'message': 'Could not generate plugin files'}), 422 plugin_id = data.get('metadata', {}).get('id', '') # _generate_plugin_files() above already validates metadata.id via this @@ -652,9 +697,10 @@ def install_locally(): # different function -- re-check here, at the point the path is actually # built, so this route stays safe on its own if that call is ever # reordered or changed. - if not _PLUGIN_ID_RE.match(plugin_id): - return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400 - target = Path(composer_bp.plugins_dir) / plugin_id + try: + target = _plugin_dir(plugin_id) + except ComposerInputError as exc: + return jsonify({'status': 'error', 'message': str(exc)}), 400 force = bool(data.get('_force', False)) if target.exists() and not force: @@ -708,7 +754,10 @@ def validate_id(plugin_id): if not _PLUGIN_ID_RE.match(plugin_id): return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'}) if composer_bp.plugins_dir: - taken = (Path(composer_bp.plugins_dir) / plugin_id).exists() + try: + taken = _plugin_dir(plugin_id).exists() + except ComposerInputError: + return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'}) if taken: return jsonify({'valid': True, 'available': False, 'reason': 'Already installed'}) return jsonify({'valid': True, 'available': True}) @@ -751,8 +800,13 @@ def preview_code(): return jsonify({'status': 'error', 'message': 'No JSON body'}), 400 try: files = _generate_plugin_files(data) - except ValueError as exc: + except ComposerInputError as exc: return jsonify({'status': 'error', 'message': str(exc)}), 422 + except ValueError as exc: + # Not one of ours: the text may name internal paths or library + # internals, so log it and answer generically. + logger.exception('Unexpected error generating plugin files: %s', exc) + return jsonify({'status': 'error', 'message': 'Could not generate plugin files'}), 422 return jsonify({ 'status': 'ok', 'files': { @@ -772,10 +826,10 @@ def load_plugin(plugin_id): """ if not composer_bp.plugins_dir: return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503 - if not _PLUGIN_ID_RE.match(plugin_id): - return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400 - - plugin_dir = Path(composer_bp.plugins_dir) / plugin_id + try: + plugin_dir = _plugin_dir(plugin_id) + except ComposerInputError as exc: + return jsonify({'status': 'error', 'message': str(exc)}), 400 if not plugin_dir.exists(): return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 From 79ba93f5a600f6e172d5e25167d82ee1ba70254c Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Fri, 21 Aug 2026 17:52:11 -0400 Subject: [PATCH 05/15] fix(composer): recognisable path containment, and define md:inline Follow-up to the previous commit, which made the CodeQL count worse rather than better: 19 alerts became 22. Two mistakes. First, the containment check used `base not in candidate.parents`. That is correct Python but not a form static analysis recognises, so every path-injection alert stayed and _plugin_dir itself picked up two more. It now uses os.path.realpath plus os.path.commonpath, which is both the documented sanitiser shape and stricter than the obvious alternative: "/x/plugins-evil" startswith "/x/plugins" but is a different directory, and there is now a test that fails if anyone swaps commonpath for startswith. Second, raising ComposerInputError from _plugin_dir and returning str(exc) added two new py/stack-trace-exposure alerts -- CodeQL flags exception text reaching a response regardless of the exception's type. _plugin_dir returns None instead and the three handlers answer with a fixed literal. There is nothing a caller needs there beyond "that id is not ok". Also defines .md\:inline in app.css. composer.html marks five toolbar button labels `hidden md:inline`, and the class was never defined, so those labels were hidden at every width and the buttons stayed icon-only. main's test_web_static_audit.py catches it -- the branch predates that test, which is why it only surfaced now that CI checks the merge: Responsive utility classes referenced in templates but never defined in app.css (they silently no-op): ['md:inline'] Verified against the merged state -- main's app.css plus this one line, audited against this branch's templates: 3 passed. The other twelve classes the audit flags locally are defined on main and are artifacts of this branch being 54 commits behind. 33 containment tests. Mutation-checked twice: removing the containment lets eight payloads escape, including /etc/passwd and plugin/../../../../../../etc/shadow; swapping commonpath for startswith fails the sibling-prefix test. Not addressed: three py/stack-trace-exposure alerts on the _generate_plugin_files handlers. Those return str(exc) for ComposerInputError, whose seven raise sites are all authored literals ("Author is required.", "Config variable key X is not a valid Python identifier."). Suppressing them means replacing useful validation feedback with a generic string, which is a real cost to the user for a scanner's benefit. Worth a decision rather than a silent downgrade. The 5 test_web_api.py failures are pre-existing on this branch -- identical counts with these changes stashed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- test/test_composer_path_containment.py | 35 ++++++++++++++------ web_interface/blueprints/composer.py | 44 +++++++++++++++----------- web_interface/static/v3/app.css | 3 ++ 3 files changed, 53 insertions(+), 29 deletions(-) diff --git a/test/test_composer_path_containment.py b/test/test_composer_path_containment.py index cc5f067b..c47ca884 100644 --- a/test/test_composer_path_containment.py +++ b/test/test_composer_path_containment.py @@ -45,19 +45,18 @@ def plugins_dir(tmp_path, monkeypatch): @pytest.mark.parametrize("payload", TRAVERSAL) def test_traversal_payloads_are_refused(plugins_dir, payload): - with pytest.raises(C.ComposerInputError): - C._plugin_dir(payload) + assert C._plugin_dir(payload) is None @pytest.mark.parametrize("payload", MALFORMED) def test_malformed_ids_are_refused(plugins_dir, payload): - with pytest.raises(C.ComposerInputError): - C._plugin_dir(payload) + assert C._plugin_dir(payload) is None @pytest.mark.parametrize("payload", ["a", "my-plugin", "x9", "a" * 63]) def test_valid_ids_resolve_inside_the_base(plugins_dir, payload): resolved = C._plugin_dir(payload) + assert resolved is not None, f"{payload!r} was rejected but is valid" assert resolved.parent == plugins_dir.resolve(), ( f"{payload!r} resolved to {resolved}, outside {plugins_dir}") @@ -71,18 +70,34 @@ def test_no_payload_can_escape_even_if_the_regex_is_loosened(plugins_dir, monkey """ import re monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z")) + import os escaped = [] + base = os.path.realpath(str(plugins_dir)) for payload in TRAVERSAL: - try: - resolved = C._plugin_dir(payload) - except C.ComposerInputError: + resolved = C._plugin_dir(payload) + if resolved is None: continue - base = plugins_dir.resolve() - if resolved != base and base not in resolved.parents: - escaped.append((payload, str(resolved))) + real = os.path.realpath(str(resolved)) + if real != base and os.path.commonpath([base, real]) != base: + escaped.append((payload, real)) assert not escaped, f"these escaped the base with a loosened regex: {escaped}" +def test_a_sibling_directory_with_a_shared_prefix_is_not_inside(tmp_path, monkeypatch): + """commonpath, not startswith. + + "/x/plugins-evil" starts with "/x/plugins" but is a different directory, so + a prefix test would accept it. + """ + base = tmp_path / "plugins" + base.mkdir() + (tmp_path / "plugins-evil").mkdir() + monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False) + import re + monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z")) + assert C._plugin_dir("../plugins-evil") is None + + def test_a_trailing_newline_is_not_a_valid_id(): r"""Python's `$` also matches before a trailing newline, so the original `^...$` accepted "myplugin\n" and would have created a directory whose diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py index 3eb0002c..eac307d1 100644 --- a/web_interface/blueprints/composer.py +++ b/web_interface/blueprints/composer.py @@ -12,7 +12,9 @@ import io import json import logging +import os import re +from typing import Optional import zipfile from datetime import datetime from pathlib import Path @@ -598,7 +600,7 @@ class ComposerInputError(ValueError): """ -def _plugin_dir(plugin_id: str) -> Path: +def _plugin_dir(plugin_id: str) -> Optional[Path]: """Resolve a plugin directory, refusing anything outside plugins_dir. _PLUGIN_ID_RE already rejects '/', '.' and '..', so this cannot currently @@ -609,15 +611,21 @@ def _plugin_dir(plugin_id: str) -> Path: recognises, which is why CodeQL reported sixteen path-injection alerts against code that was already safe. - Raises ComposerInputError if the id is malformed or escapes the base. + Returns None for a malformed id or one that escapes the base. It returns + rather than raises so the handlers answer with a fixed literal: routing a + caught exception's text into a response is what py/stack-trace-exposure + flags, and there is nothing here a caller needs beyond "that id is not ok". """ if not _PLUGIN_ID_RE.match(plugin_id or ''): - raise ComposerInputError('Invalid plugin ID') - base = Path(composer_bp.plugins_dir).resolve() - candidate = (base / plugin_id).resolve() - if candidate != base and base not in candidate.parents: - raise ComposerInputError('Invalid plugin ID') - return candidate + return None + base = os.path.realpath(str(composer_bp.plugins_dir)) + candidate = os.path.realpath(os.path.join(base, plugin_id)) + # commonpath, not startswith: "/plugins-evil" starts with "/plugins" but is + # a different directory. This is also the form static analysis recognises + # as a containment check. + if candidate != base and os.path.commonpath([base, candidate]) != base: + return None + return Path(candidate) def _save_composer_state(target_dir: Path, payload: dict) -> None: @@ -697,10 +705,9 @@ def install_locally(): # different function -- re-check here, at the point the path is actually # built, so this route stays safe on its own if that call is ever # reordered or changed. - try: - target = _plugin_dir(plugin_id) - except ComposerInputError as exc: - return jsonify({'status': 'error', 'message': str(exc)}), 400 + target = _plugin_dir(plugin_id) + if target is None: + return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400 force = bool(data.get('_force', False)) if target.exists() and not force: @@ -754,10 +761,10 @@ def validate_id(plugin_id): if not _PLUGIN_ID_RE.match(plugin_id): return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'}) if composer_bp.plugins_dir: - try: - taken = _plugin_dir(plugin_id).exists() - except ComposerInputError: + resolved = _plugin_dir(plugin_id) + if resolved is None: return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'}) + taken = resolved.exists() if taken: return jsonify({'valid': True, 'available': False, 'reason': 'Already installed'}) return jsonify({'valid': True, 'available': True}) @@ -826,10 +833,9 @@ def load_plugin(plugin_id): """ if not composer_bp.plugins_dir: return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503 - try: - plugin_dir = _plugin_dir(plugin_id) - except ComposerInputError as exc: - return jsonify({'status': 'error', 'message': str(exc)}), 400 + plugin_dir = _plugin_dir(plugin_id) + if plugin_dir is None: + return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400 if not plugin_dir.exists(): return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 diff --git a/web_interface/static/v3/app.css b/web_interface/static/v3/app.css index a1342955..fa73108b 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -421,6 +421,9 @@ a, button, input, select, textarea { .md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } .md\:flex { display: flex; } .md\:hidden { display: none; } + /* composer.html labels its toolbar buttons `hidden md:inline`, so without + this the label is hidden at every width and the buttons stay icon-only. */ + .md\:inline { display: inline; } } @media (min-width: 1024px) { From 5929190e36e88b6a3a468a3643cc3b4576c14b5f Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Fri, 21 Aug 2026 18:25:50 -0400 Subject: [PATCH 06/15] fix(composer): sanitise the id with the form CodeQL recognises Previous attempt got the count from 22 down to 19 but left the 16 path-injection alerts untouched: CodeQL carries taint through _plugin_dir's return value and does not treat an internal realpath / commonpath guard as a sanitiser. secure_filename is one it does model. It is also a no-op on every id the regex accepts -- verified across the accepted alphabet, 4000 generated ids, zero altered -- so it cannot rewrite a caller's id into a different plugin's directory. The equality check makes that explicit: if it changes anything, the id was not one we accept, and we refuse rather than silently redirect. Found a real bug while testing the layers separately: '.' resolved to the plugins root, and install() calls shutil.rmtree(target) when force is set, so an id of '.' would have deleted every installed plugin. The regex blocks it today, but the containment layer was allowing candidate == base on the grounds that the base is not "outside" itself. A plugin directory must be a child, never the root. That came out of writing the isolated tests. Removing containment did not fail anything, because secure_filename rejects traversal first -- which made a redundant layer look load-bearing. Each layer is now neutralised in turn so the one under test is the only thing standing: containment removed -> FAIL (13 payloads reach the base or past it) candidate == base allowed -> FAIL ('.' resolves to the plugins root) commonpath -> startswith -> FAIL (sibling "plugins-evil" accepted) secure_filename bypassed -> pass, containment covers it The last is honest rather than a gap: with containment in place the sanitiser has nothing left to block, and its value here is CodeQL recognition plus a second barrier if containment is ever weakened. Also corrected an assertion in the previous commit's test, which counted any non-None result as an escape. '....', '~' and 'a\..\..' are ordinary directory names on Linux and resolve safely inside the base; treating them as escapes made the test fail on correct code. 35 tests. The 5 test_web_api.py failures are pre-existing on this branch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- test/test_composer_path_containment.py | 56 ++++++++++++++++++++++++++ web_interface/blueprints/composer.py | 17 +++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/test/test_composer_path_containment.py b/test/test_composer_path_containment.py index c47ca884..f8e1d090 100644 --- a/test/test_composer_path_containment.py +++ b/test/test_composer_path_containment.py @@ -94,10 +94,66 @@ def test_a_sibling_directory_with_a_shared_prefix_is_not_inside(tmp_path, monkey (tmp_path / "plugins-evil").mkdir() monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False) import re + # Neutralise the two layers in front so this exercises the containment + # check itself; otherwise secure_filename rejects the payload first and a + # startswith regression would go unnoticed here. monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z")) + monkeypatch.setattr(C, "secure_filename", lambda v: v) assert C._plugin_dir("../plugins-evil") is None +def test_containment_still_holds_if_the_sanitiser_is_defeated(plugins_dir, monkeypatch): + """Each layer is tested on its own, not just the stack. + + secure_filename's equality guard rejects every traversal payload before the + containment check sees it, so removing containment does not fail the other + tests -- which would make it look load-bearing when it is not. Neutralise + the regex *and* the sanitiser, and the realpath/commonpath check must still + refuse everything on its own. + """ + import re + monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z")) + monkeypatch.setattr(C, "secure_filename", lambda v: v) + import os + base = os.path.realpath(str(plugins_dir)) + escaped = [] + for payload in TRAVERSAL: + resolved = C._plugin_dir(payload) + if resolved is None: + continue + real = os.path.realpath(str(resolved)) + # Inside the base is fine -- "...." and "~" are ordinary directory + # names on Linux, so they are not escapes. What must never happen is + # landing outside the base, or on the base itself: install() rmtrees + # its target, so the plugins root resolving to a "plugin" would wipe + # every installed plugin. + if real == base or os.path.commonpath([base, real]) != base: + escaped.append((payload, real)) + assert not escaped, f"containment alone let these through: {escaped}" + + +def test_secure_filename_never_rewrites_an_accepted_id(plugins_dir): + """The sanitiser must be a no-op on everything the regex accepts. + + If secure_filename ever altered an accepted id, _plugin_dir would resolve + to a *different* plugin's directory than the caller asked for -- a silent + redirect, which is worse than a refusal. The guard turns that into a + refusal; this proves the guard never has to fire in practice. + """ + import random + from werkzeug.utils import secure_filename + random.seed(1) + alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-" + altered = [] + for _ in range(2000): + n = random.randint(1, 63) + cand = random.choice("abcdefghijklmnopqrstuvwxyz") + "".join( + random.choice(alphabet) for _ in range(n - 1)) + if C._PLUGIN_ID_RE.match(cand) and secure_filename(cand) != cand: + altered.append((cand, secure_filename(cand))) + assert not altered, f"secure_filename rewrote accepted ids: {altered[:5]}" + + def test_a_trailing_newline_is_not_a_valid_id(): r"""Python's `$` also matches before a trailing newline, so the original `^...$` accepted "myplugin\n" and would have created a directory whose diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py index eac307d1..0fd360c5 100644 --- a/web_interface/blueprints/composer.py +++ b/web_interface/blueprints/composer.py @@ -22,6 +22,7 @@ import jinja2 import jsonschema from flask import Blueprint, jsonify, render_template, request, send_file +from werkzeug.utils import secure_filename logger = logging.getLogger(__name__) @@ -618,12 +619,24 @@ def _plugin_dir(plugin_id: str) -> Optional[Path]: """ if not _PLUGIN_ID_RE.match(plugin_id or ''): return None + # secure_filename strips path separators and traversal. Every id the regex + # above accepts passes through it byte-for-byte -- verified across the whole + # accepted alphabet -- so this cannot rewrite a caller's id into a + # different plugin's directory; if it changes anything, the id was not one + # we accept and we refuse rather than silently redirect. + safe_id = secure_filename(plugin_id) + if safe_id != plugin_id: + return None base = os.path.realpath(str(composer_bp.plugins_dir)) - candidate = os.path.realpath(os.path.join(base, plugin_id)) + candidate = os.path.realpath(os.path.join(base, safe_id)) + # A plugin directory must be a *child* of the base, never the base itself: + # install() calls shutil.rmtree(target) when force is set, so resolving to + # the plugins root would delete every installed plugin. + # # commonpath, not startswith: "/plugins-evil" starts with "/plugins" but is # a different directory. This is also the form static analysis recognises # as a containment check. - if candidate != base and os.path.commonpath([base, candidate]) != base: + if candidate == base or os.path.commonpath([base, candidate]) != base: return None return Path(candidate) From 51336436007ff08f2799b406e323a868786f2266 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Fri, 21 Aug 2026 18:56:00 -0400 Subject: [PATCH 07/15] fix(composer): build the font path from the allowlist entry secure_filename cleared the plugin-directory alerts: CodeQL went from 19 to 5, and from 16 high-severity to 2. The two that remain are in serve_font, which is gated by a frozenset of three exact filenames -- so nothing was exploitable -- but the name reaching the filesystem was still the request value. It now comes from the matched allowlist entry. Identical strings, so runtime behaviour is unchanged; the difference is that the filename is provably a module constant rather than a guarded piece of user input. The first test I wrote for this proved nothing. It asserted 404 on traversal payloads, but Flask's router will not match a path segment containing '/', and the rest 404 simply because no such file exists -- so removing the allowlist entirely still passed. Replaced with a readable file planted next to the fonts: fonts/id_rsa.ttf -> 404, body does not contain its contents which fails with "a readable non-allowlisted file was served" the moment the gate is removed. 45 tests. Left alone: three medium py/stack-trace-exposure alerts on the _generate_plugin_files handlers, which return str(exc) for ComposerInputError. Its seven raise sites are all authored literals ("Author is required.", "Config variable key X is not a valid Python identifier."), so no traceback or path is exposed. Clearing them means either replacing that feedback with a generic string or restructuring validation to return errors instead of raising -- a change to the author's design, made blind, since CodeQL cannot be run locally to confirm it would even work. That is a decision, not a cleanup. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- test/test_composer_path_containment.py | 63 ++++++++++++++++++++++++++ web_interface/blueprints/composer.py | 10 +++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/test/test_composer_path_containment.py b/test/test_composer_path_containment.py index f8e1d090..f0f760b6 100644 --- a/test/test_composer_path_containment.py +++ b/test/test_composer_path_containment.py @@ -160,3 +160,66 @@ def test_a_trailing_newline_is_not_a_valid_id(): name ends in one. \Z does not.""" assert C._PLUGIN_ID_RE.match("myplugin") is not None assert C._PLUGIN_ID_RE.match("myplugin\n") is None + + +# --- font serving ----------------------------------------------------------- + +FONT_TRAVERSAL = [ + "../../../etc/passwd", "../config/config.json", "..%2f..%2fetc%2fpasswd", + "PressStart2P-Regular.ttf/../../../etc/passwd", "/etc/passwd", "", + "PressStart2P-Regular.TTF", # case differs -> not the allowlisted name + "PressStart2P-Regular.ttf ", # trailing space +] + + +def test_serve_font_refuses_a_file_that_exists_but_is_not_allowlisted(monkeypatch, tmp_path): + """The allowlist must be what refuses it, not a missing file. + + Asserting 404 on traversal payloads proves nothing here: Flask's router + will not match a path segment containing '/', and everything else 404s + simply because no such file exists. Put a real, readable file next to the + fonts and confirm it is still refused -- that is the allowlist working. + """ + fonts = tmp_path / "assets" / "fonts" + fonts.mkdir(parents=True) + (fonts / "id_rsa.ttf").write_bytes(b"PRIVATE KEY") + monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False) + app = __import__("flask").Flask(__name__) + app.register_blueprint(C.composer_bp) + with app.test_client() as client: + resp = client.get("/api/fonts/id_rsa.ttf") + assert resp.status_code == 404, ( + "a readable non-allowlisted file was served; the allowlist is not gating") + assert b"PRIVATE KEY" not in resp.data + + +@pytest.mark.parametrize("payload", FONT_TRAVERSAL) +def test_serve_font_refuses_anything_not_allowlisted(payload, monkeypatch, tmp_path): + """The name reaching the filesystem must come from the allowlist constant. + + _ALLOWED_FONTS gates this endpoint, so nothing here was ever exploitable. + Building the path from the matched constant rather than the request value + is what makes that provable -- and it is why CodeQL reported two + high-severity py/path-injection alerts on an endpoint that was already + safe. + """ + monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False) + app = C.composer_bp.name and __import__("flask").Flask(__name__) + app.register_blueprint(C.composer_bp) + with app.test_client() as client: + resp = client.get(f"/api/fonts/{payload}") + assert resp.status_code in (404, 405, 308), ( + f"{payload!r} was not refused (status {resp.status_code})") + + +def test_serve_font_still_serves_each_allowlisted_font(monkeypatch, tmp_path): + fonts = tmp_path / "assets" / "fonts" + fonts.mkdir(parents=True) + monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False) + app = __import__("flask").Flask(__name__) + app.register_blueprint(C.composer_bp) + for name in C._ALLOWED_FONTS: + (fonts / name).write_bytes(b"\x00\x01ttf") + with app.test_client() as client: + resp = client.get(f"/api/fonts/{name}") + assert resp.status_code == 200, f"{name} should be served, got {resp.status_code}" diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py index 0fd360c5..88e2f2c7 100644 --- a/web_interface/blueprints/composer.py +++ b/web_interface/blueprints/composer.py @@ -758,11 +758,17 @@ def install_locally(): @composer_bp.route('/api/fonts/') def serve_font(font_name): """Serve an allowlisted font file for canvas FontFace loading.""" - if font_name not in _ALLOWED_FONTS: + # Build the path from the allowlist entry, not from the request value. + # They are equal strings, so this changes nothing at runtime -- but the + # name that reaches the filesystem now provably originates in a module + # constant, which is the difference between "guarded" and "not derived + # from user input at all". + allowed_name = next((f for f in sorted(_ALLOWED_FONTS) if f == font_name), None) + if allowed_name is None: return '', 404 if not composer_bp.project_root: return '', 503 - font_path = Path(composer_bp.project_root) / 'assets' / 'fonts' / font_name + font_path = Path(composer_bp.project_root) / 'assets' / 'fonts' / allowed_name if not font_path.exists(): return '', 404 return send_file(str(font_path), mimetype='font/ttf') From e450a6dfb639e70a9df3849da526cf03291c5210 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Fri, 21 Aug 2026 19:03:13 -0400 Subject: [PATCH 08/15] fix(composer): stop payload text reaching generated Python as code Review flagged this as critical and it is: the composer builds manager.py by interpolating payload values into source text, /api/install writes that file into plugins_dir, and the plugin loader imports and executes it. The ast.parse check further down rejects only *invalid* syntax, and an injected `import os` is perfectly valid. Confirmed against the code before this commit. A plugin name carrying a triple quote closes the module docstring and everything after it becomes module-level code: generated manager.py parses: True injected module-level statements: ['import os', 'PWNED = os.getuid()'] and a geometry value is interpolated verbatim, because the parameter is annotated int but arrives as JSON: _compute_pos_expr('0 or __import__("os").system("id")', 'right', 'width') -> 'width - 0 or __import__("os").system("id")' generated source: x=0 or __import__("os").system("id"), Three fixes. _safe_int coerces and optionally clamps, and _compute_pos_expr applies it to its own argument -- which covers all twenty-odd call sites at once rather than patching each. _rgb_expr does the same for the eight colour interpolations, clamping channels to 0-255. Line endpoints and widths go through it too. For the docstring, _reject_source_breaking refuses a plugin name containing a quote, backslash or newline. Rejecting rather than escaping: these are display names, none of that belongs in one, and a clear "Plugin name cannot contain a double quote." beats silently mangling what the user typed. Verified: all three exploits now refused or neutered, and each defence mutation-checked separately -- coercion removed in _compute_pos_expr -> 8 failed docstring guard removed -> 5 failed colour channels interpolated raw -> 13 failed 87 tests, covering seven expression payloads across seven geometry fields and three colour channels, five literal-breaking names, and the clean case asserting a normal payload still yields no module-level statements at all. One aside: the first version of this test file put the exploit string in its own module docstring, which closed it and made the file a syntax error -- the same bug, one level up. It now describes the payload rather than embedding it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- test/test_composer_code_injection.py | 117 +++++++++++++++++++++++++++ web_interface/blueprints/composer.py | 88 ++++++++++++++++---- 2 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 test/test_composer_code_injection.py diff --git a/test/test_composer_code_injection.py b/test/test_composer_code_injection.py new file mode 100644 index 00000000..542ed378 --- /dev/null +++ b/test/test_composer_code_injection.py @@ -0,0 +1,117 @@ +"""The composer generates Python that the plugin loader imports and executes. + +/api/install writes the generated manager.py into plugins_dir and the loader +imports it, so anything the payload can splice into that source runs on the +device. The ast.parse check in _generate_plugin_files rejects only *invalid* +syntax -- an injected `import os` is perfectly valid and passed it. + +Two ways in, both confirmed against the code before it was fixed: + + metadata.name = a name containing a triple-quote, a newline, then + `import os; PWNED = os.getuid()`, then another triple-quote + -> closes the module docstring; the rest became module-level statements + (spelled out rather than shown literally -- writing the payload into + this docstring closes *this* file's docstring, which is the bug) + + element x = '0 or __import__("os").system("id")' + -> f-string interpolated it verbatim: x=0 or __import__("os").system("id") +""" +import ast +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from web_interface.blueprints import composer as C # noqa: E402 + +BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a", + "version": "1.0.0", "description": "d"} + +#: Values that terminate a Python expression and start a new statement. +EXPR_PAYLOADS = [ + '0 or __import__("os").system("id")', + '0);import os;os.system("id");(', + '__import__("subprocess").run(["id"])', + "0 if False else exec('x=1')", + "1e999", "nan", "0x41", "0__0", +] + +#: Values that close a string literal in the generated source. +LITERAL_PAYLOADS = [ + 'Clock"""\nimport os; PWNED = os.getuid()\n"""', + "Clock'''\nimport os\n'''", + 'Clock" + __import__("os").system("id") + "', + "Clock\\", "Clock\nimport os", +] + + +def _payload(**over): + p = {"metadata": dict(BASE_META), "elements": [], "config_vars": []} + p["metadata"].update(over.pop("metadata", {})) + p.update(over) + return p + + +def _generated(payload): + return C._generate_plugin_files(payload)["manager.py"] + + +def _module_level_code(src): + """Statements at module level that are not the docstring/imports/classes.""" + tree = ast.parse(src) + out = [] + for node in tree.body: + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.ImportFrom)): + continue + if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant): + continue # the docstring + out.append(ast.unparse(node)) + return out + + +@pytest.mark.parametrize("payload", LITERAL_PAYLOADS) +def test_a_name_that_breaks_out_of_a_literal_is_refused(payload): + with pytest.raises(C.ComposerInputError): + _generated(_payload(metadata={"name": payload})) + + +@pytest.mark.parametrize("evil", EXPR_PAYLOADS) +@pytest.mark.parametrize("field", ["x", "y", "x0", "y0", "x1", "y1", "lineWidth"]) +def test_a_non_numeric_geometry_value_cannot_reach_the_source(evil, field): + el = {"type": "line", "id": "l1", "x0": 0, "y0": 0, "x1": 10, "y1": 10, + "anchor_x": "right", "anchor_y": "bottom"} + el[field] = evil + src = _generated(_payload(elements=[el])) + assert "__import__" not in src, f"{field}={evil!r} reached the generated source" + assert "os.system" not in src + assert not _module_level_code(src), \ + f"{field}={evil!r} produced module-level statements: {_module_level_code(src)}" + + +@pytest.mark.parametrize("evil", EXPR_PAYLOADS) +@pytest.mark.parametrize("channel", ["r", "g", "b"]) +def test_a_non_numeric_colour_channel_cannot_reach_the_source(evil, channel): + el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi", + "font": "press_start", "r": 255, "g": 255, "b": 255} + el[channel] = evil + src = _generated(_payload(elements=[el])) + assert "__import__" not in src and "os.system" not in src + assert not _module_level_code(src) + + +def test_colour_channels_are_clamped_to_a_byte(): + el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi", + "font": "press_start", "r": 99999, "g": -5, "b": 128} + src = _generated(_payload(elements=[el])) + assert "(255, 0, 128)" in src, "channels were not clamped to 0-255" + + +def test_the_generated_module_still_has_no_top_level_statements(): + """The clean case: a normal payload produces only imports and a class.""" + el = {"type": "text", "id": "t1", "x": 4, "y": 4, "text": "hi", + "font": "press_start", "r": 1, "g": 2, "b": 3} + src = _generated(_payload(elements=[el])) + assert not _module_level_code(src) + assert "(1, 2, 3)" in src diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py index 88e2f2c7..544aa086 100644 --- a/web_interface/blueprints/composer.py +++ b/web_interface/blueprints/composer.py @@ -101,13 +101,62 @@ def _to_class_name(name: str) -> str: return base if base.endswith('Plugin') else base + 'Plugin' -def _compute_pos_expr(val: int, anchor: str | None, dim_var: str) -> str: +def _reject_source_breaking(value: str, field: str) -> None: + """Refuse text that could terminate a string literal in generated source. + + Anything interpolated into manager.py inside quotes has to survive being + read back as Python. A quote, a backslash or a newline can end the literal + early and turn the remainder into executable statements. + """ + for bad, label in (('"', 'a double quote'), ("'", 'a single quote'), + ('\\', 'a backslash'), ('\n', 'a newline'), + ('\r', 'a carriage return')): + if bad in value: + raise ComposerInputError( + f'{field} cannot contain {label}.') + + +def _safe_int(value, default: int = 0, lo: int | None = None, + hi: int | None = None) -> int: + """Coerce a payload value to int, falling back rather than raising. + + Everything this module interpolates into generated Python has to go + through here first. The payload is JSON from the browser, so a field + annotated `int` can arrive as any string, and these values are formatted + straight into `manager.py` -- which /api/install writes to disk and the + plugin loader then imports and executes. An x of + + '0 or __import__("os").system("id")' + + produced `x=0 or __import__("os").system("id")` in the generated source, + which is valid Python and so passed the ast.parse check. + """ + try: + out = int(value) + except (TypeError, ValueError): + return default + if lo is not None: + out = max(lo, out) + if hi is not None: + out = min(hi, out) + return out + + +def _rgb_expr(el: dict, dr: int = 255, dg: int = 255, db: int = 255) -> str: + """A colour tuple literal built from coerced, clamped channel values.""" + return (f"({_safe_int(el.get('r'), dr, 0, 255)}, " + f"{_safe_int(el.get('g'), dg, 0, 255)}, " + f"{_safe_int(el.get('b'), db, 0, 255)})") + + +def _compute_pos_expr(val, anchor: str | None, dim_var: str) -> str: """Produce a Python expression string for an anchored or fixed position. anchor=None/'left'/'top' → fixed pixel value anchor='center' → dim_var // 2 ± offset anchor='right'/'bottom' → dim_var - offset """ + val = _safe_int(val, 0) if not anchor or anchor in ('left', 'top'): return str(val) if anchor in ('center', 'middle'): @@ -170,7 +219,7 @@ def _preprocess_elements(elements: list) -> list: if t in ('text', 'clock'): font_key = el.get('font', 'press_start') p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font') - p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})" + p['rgb_tuple'] = _rgb_expr(el, 255, 255, 255) text_align = el.get('textAlign', 'left') raw_x = el.get('x', 0) x_base_expr = _compute_pos_expr(raw_x, x_anchor, 'width') @@ -206,7 +255,7 @@ def _preprocess_elements(elements: list) -> list: p['binding_format'] = binding.get('format') font_key = el.get('font', 'press_start') p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font') - p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 100)})" + p['rgb_tuple'] = _rgb_expr(el, 255, 200, 100) x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') p['x_expr'] = x_base_expr # dynamic text: runtime content determines width; use raw pos p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') @@ -248,10 +297,10 @@ def _preprocess_elements(elements: list) -> list: else: p['x0_expr'] = _compute_pos_expr(el.get('x0', 0), x_anchor, 'width') p['y0_expr'] = _compute_pos_expr(el.get('y0', 0), y_anchor, 'height') - p['x1_expr'] = str(el.get('x1', 127)) - p['y1_expr'] = str(el.get('y1', 0)) - p['rgb_tuple'] = f"({el.get('r', 180)}, {el.get('g', 180)}, {el.get('b', 180)})" - p['line_width'] = el.get('lineWidth', 1) + p['x1_expr'] = str(_safe_int(el.get('x1'), 127)) + p['y1_expr'] = str(_safe_int(el.get('y1'), 0)) + p['rgb_tuple'] = _rgb_expr(el, 180, 180, 180) + p['line_width'] = _safe_int(el.get('lineWidth'), 1, 1, 64) p['blink'] = bool(el.get('blink', False)) elif t == 'progress_bar': @@ -285,8 +334,8 @@ def _preprocess_elements(elements: list) -> list: p['y2_expr'] = f"({y_expr}) + {h}" p['start_angle'] = int(el.get('startAngle', 0)) p['end_angle'] = int(el.get('endAngle', 270)) - p['line_width'] = max(1, int(el.get('lineWidth', 2))) - p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})" + p['line_width'] = _safe_int(el.get('lineWidth'), 2, 1, 64) + p['rgb_tuple'] = _rgb_expr(el, 255, 200, 0) p['blink'] = bool(el.get('blink', False)) elif t == 'ellipse': @@ -313,7 +362,7 @@ def _preprocess_elements(elements: list) -> list: elif t == 'pixel': p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})" + p['rgb_tuple'] = _rgb_expr(el, 255, 255, 255) p['blink'] = bool(el.get('blink', False)) elif t == 'rounded_rectangle': @@ -341,7 +390,7 @@ def _preprocess_elements(elements: list) -> list: elif t == 'countdown': font_key = el.get('font', 'four_by_six') p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font') - p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 180)}, {el.get('b', 0)})" + p['rgb_tuple'] = _rgb_expr(el, 255, 180, 0) binding = el.get('binding', {}) p['binding_key'] = binding.get('key', '') p['countdown_format'] = el.get('countdownFormat', 'dh') @@ -390,8 +439,8 @@ def _preprocess_elements(elements: list) -> list: p['y2_expr'] = f"({y_expr}) + {h}" p['start_angle'] = int(el.get('startAngle', 135)) p['end_angle'] = int(el.get('endAngle', 45)) - p['line_width'] = max(1, int(el.get('lineWidth', 3))) - p['rgb_tuple'] = f"({el.get('r', 80)}, {el.get('g', 220)}, {el.get('b', 80)})" + p['line_width'] = _safe_int(el.get('lineWidth'), 3, 1, 64) + p['rgb_tuple'] = _rgb_expr(el, 80, 220, 80) track = ( [el.get('trackR', 40), el.get('trackG', 40), el.get('trackB', 40)] if el.get('hasTrack', True) else None @@ -408,7 +457,7 @@ def _preprocess_elements(elements: list) -> list: elif t == 'marquee': font_key = el.get('font', 'press_start') p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font') - p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})" + p['rgb_tuple'] = _rgb_expr(el, 255, 255, 255) p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') p['text'] = el.get('text', 'Scrolling text') p['char_w'] = _FONT_CHAR_W.get(font_key, 8) @@ -446,6 +495,17 @@ def _generate_plugin_files(data: dict) -> dict: plugin_name = metadata.get('name', '').strip() if not plugin_name: raise ComposerInputError('Plugin name is required.') + # The template drops this straight into manager.py's module docstring. A + # name carrying a triple quote closes that docstring and everything after + # it becomes module-level code, which /api/install writes to disk and the + # loader imports and runs: + # + # Clock"""\nimport os; PWNED = os.getuid()\n""" + # -> import os <- executed on load + # PWNED = os.getuid() + # + # ast.parse further down only rejects invalid syntax, and that is valid. + _reject_source_breaking(plugin_name, 'Plugin name') author = metadata.get('author', '').strip() if not author: From 986f74e38bd7c12e2f2fd9934bb54ee3709ca4ca Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Fri, 21 Aug 2026 19:38:54 -0400 Subject: [PATCH 09/15] fix(composer): reject config keys that shadow plugin state Five review findings, plus the two bandit reported. Config variable keys were checked against an identifier regex only. Python keywords slipped past it and were caught downstream by ast.parse, but reported as Generated code has a syntax error: invalid syntax (, line 17) which names neither the field nor the value. They are now refused by name, soft keywords ('match', 'case') included. Worse, a key matching a BasePlugin attribute generated *valid* code that silently clobbered plugin state. 'config' is the sharp one: the assignment lands immediately after super().__init__(), so self.config = config.get("config", "x") replaces the plugin's config dict with a string, and every later self.config.get(...) fails at runtime. Refused now, along with logger, display_manager, cache_manager, plugin_id, enabled, self and the lifecycle method names. A test pins the ordering assumption that reserved list rests on, so it fails if config vars are ever emitted before super().__init__() instead. Also: - The silent `except Exception: pass` around manifest parsing now logs. It left "partial import produced nothing" indistinguishable from a malformed manifest. (bandit B110) - list_plugins() called iterdir() on a directory that may not exist -- a fresh install or a bad path returned 500 instead of an empty list. - metadata.id is stripped in the two route handlers, matching _generate_plugin_files, which strips before validating. Without it " my-plugin " generated fine and then failed the id check at install, reading as a generator bug. - The jinja Environment's autoescape=False now says why: these templates emit Python, and escaping a quote to " inside generated code would break it. Safety comes from the values instead -- _safe_int, _rgb_expr and _reject_source_breaking, all covered by the injection suite. (bandit B701, marked nosec with that rationale) bandit on composer.py: 2 findings -> 0. Verified: 156 tests across the two composer suites. Removing either new key check fails 9. Not reproduced: the suggestion to emit `pass` so a conditional block is never empty. 'line' and 'divider' render through a different template branch and 'section' emits nothing at all, so no element type available here produces an `if width >= N:` with an empty body. Left alone rather than changing template output speculatively. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- test/test_composer_code_injection.py | 54 +++++++++++++++++++++++++++ web_interface/blueprints/composer.py | 56 +++++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/test/test_composer_code_injection.py b/test/test_composer_code_injection.py index 542ed378..54a5b77b 100644 --- a/test/test_composer_code_injection.py +++ b/test/test_composer_code_injection.py @@ -115,3 +115,57 @@ def test_the_generated_module_still_has_no_top_level_statements(): src = _generated(_payload(elements=[el])) assert not _module_level_code(src) assert "(1, 2, 3)" in src + + +# --- config variable keys --------------------------------------------------- + +def _with_key(key): + return {"metadata": dict(BASE_META), "elements": [], + "dataModel": {"configVars": [{"key": key, "type": "string", + "default": "x", "label": "L"}]}} + + +@pytest.mark.parametrize("key", ["class", "def", "import", "None", "True", + "lambda", "pass", "match", "case"]) +def test_a_keyword_config_key_is_named_in_the_error(key): + """ast.parse already rejected these, but as an unhelpful line number. + + "Generated code has a syntax error: invalid syntax (line 17)" tells the + user nothing about which field to fix. + """ + with pytest.raises(C.ComposerInputError) as exc: + _generated(_with_key(key)) + assert key in str(exc.value) and "keyword" in str(exc.value).lower() + + +@pytest.mark.parametrize("key", ["config", "logger", "display_manager", + "cache_manager", "plugin_id", "enabled", + "self", "update", "display"]) +def test_a_reserved_attribute_config_key_is_refused(key): + """These generate *valid* Python that silently clobbers plugin state. + + The worst is `config`: the assignment lands right after super().__init__(), + so `self.config = config.get("config", "x")` replaces the plugin's config + dict with a string and every later self.config.get(...) fails at runtime. + """ + with pytest.raises(C.ComposerInputError) as exc: + _generated(_with_key(key)) + assert key in str(exc.value) and "reserved" in str(exc.value).lower() + + +@pytest.mark.parametrize("key", ["brightness", "my_var", "_private", "x1", + "update_interval_seconds"]) +def test_ordinary_config_keys_are_still_accepted(key): + src = _generated(_with_key(key)) + assert f"self.{key} = config.get(" in src + + +def test_the_generated_config_assignment_does_not_precede_super_init(): + """Guards the reasoning behind the reserved list, not just the list.""" + src = _generated(_with_key("brightness")) + body = src.splitlines() + super_at = next(i for i, l in enumerate(body) if "super().__init__(" in l) + assign_at = next(i for i, l in enumerate(body) if "self.brightness = config.get(" in l) + assert assign_at > super_at, ( + "config vars are assigned before super().__init__(); the reserved-name " + "list assumes they land after it") diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py index 544aa086..010395df 100644 --- a/web_interface/blueprints/composer.py +++ b/web_interface/blueprints/composer.py @@ -11,6 +11,7 @@ import ast import io import json +import keyword import logging import os import re @@ -67,8 +68,16 @@ def _get_jinja_env() -> jinja2.Environment: global _jinja_env if _jinja_env is None: template_dir = Path(__file__).parent.parent / 'templates' / 'v3' / 'composer' - _jinja_env = jinja2.Environment( + _jinja_env = jinja2.Environment( # nosec B701 - see below loader=jinja2.FileSystemLoader(str(template_dir)), + # These templates emit Python source, not HTML. Autoescaping would + # turn a quote in a plugin name into " inside generated code + # and break it, so it stays off deliberately -- and the safety has + # to come from the values instead. It does: every numeric value is + # coerced by _safe_int/_rgb_expr, and text that could terminate a + # string literal is rejected by _reject_source_breaking. Both are + # covered by test/test_composer_code_injection.py, which is where + # to look before relaxing any of it. autoescape=False, trim_blocks=True, lstrip_blocks=True, @@ -101,6 +110,16 @@ def _to_class_name(name: str) -> str: return base if base.endswith('Plugin') else base + 'Plugin' +#: Attribute names BasePlugin (or the generated __init__) already owns. A +#: config var using one of these produces valid Python that quietly clobbers +#: the plugin's own state instead of failing loudly. +_RESERVED_ATTRS = frozenset({ + 'config', 'logger', 'display_manager', 'cache_manager', 'plugin_manager', + 'plugin_id', 'enabled', 'global_config', 'self', 'update', 'display', + 'validate_config', 'get_info', 'cleanup', +}) + + def _reject_source_breaking(value: str, field: str) -> None: """Refuse text that could terminate a string literal in generated source. @@ -518,6 +537,22 @@ def _generate_plugin_files(data: dict) -> dict: key = cv.get('key', '') if not _PYTHON_IDENT_RE.match(key): raise ComposerInputError(f'Config variable key "{key}" is not a valid Python identifier.') + # A keyword produces `self.class = ...`, which the ast.parse check + # below does catch -- but as "Generated code has a syntax error: + # invalid syntax (line 17)", which tells the user nothing about which + # field to fix. + if keyword.iskeyword(key) or keyword.issoftkeyword(key): + raise ComposerInputError( + f'Config variable key "{key}" is a Python keyword.') + # These generate *valid* code that silently shadows the plugin's own + # state. "config" is the worst: the assignment runs immediately after + # super().__init__(), so + # self.config = config.get("config", "x") + # replaces the plugin's config dict with a string and every later + # self.config.get(...) fails at runtime. + if key in _RESERVED_ATTRS: + raise ComposerInputError( + f'Config variable key "{key}" is reserved by BasePlugin.') class_name = _to_class_name(plugin_name) # Only consider visible elements for code generation flags @@ -742,7 +777,10 @@ def generate_zip(): logger.exception('Unexpected error generating plugin files: %s', exc) return jsonify({'status': 'error', 'message': 'Could not generate plugin files'}), 422 - plugin_id = data.get('metadata', {}).get('id', 'plugin') + # .strip() to match _generate_plugin_files, which strips before it + # validates. Without it " my-plugin " generates successfully and then + # fails the id check here, which reads as a bug in the generator. + plugin_id = data.get('metadata', {}).get('id', 'plugin').strip() or 'plugin' files['_composer_state.json'] = json.dumps(data, indent=2, ensure_ascii=False) zip_buf = _pack_zip(files, plugin_id) return send_file( @@ -772,7 +810,7 @@ def install_locally(): logger.exception('Unexpected error generating plugin files: %s', exc) return jsonify({'status': 'error', 'message': 'Could not generate plugin files'}), 422 - plugin_id = data.get('metadata', {}).get('id', '') + plugin_id = data.get('metadata', {}).get('id', '').strip() # _generate_plugin_files() above already validates metadata.id via this # same regex before it will return, but that guarantee lives in a # different function -- re-check here, at the point the path is actually @@ -855,6 +893,12 @@ def list_plugins(): if not composer_bp.plugins_dir: return jsonify([]) plugins_dir = Path(composer_bp.plugins_dir) + if not plugins_dir.is_dir(): + # Configured but not created yet -- a fresh install, or a bad path. + # iterdir() raises FileNotFoundError/NotADirectoryError here, which + # surfaced as a 500 rather than "no plugins". + logger.warning("Plugin directory %s does not exist", plugins_dir) + return jsonify([]) results = [] for entry in sorted(plugins_dir.iterdir()): if not entry.is_dir(): @@ -964,8 +1008,10 @@ def load_plugin(plugin_id): if manifest_path.exists(): try: manifest = json.loads(manifest_path.read_text()) - except Exception: - pass + except (OSError, ValueError) as exc: + # Swallowing this left "partial import produced nothing" with no + # way to tell a malformed manifest from an absent one. + logger.warning("Failed to parse manifest.json for %s: %s", plugin_id, exc) partial_state = { 'composer_version': '1.0', From f0bef7784c6c6e87fb11c1785ca0cfdaddfc2da7 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Fri, 21 Aug 2026 20:14:52 -0400 Subject: [PATCH 10/15] fix(composer): eight editor bugs from review All confirmed by reading the code rather than taken on trust. Saved designs restored onto the wrong canvas. _buildPayload writes the size as `preset`; _applyState read `state.currentPreset`, which is never present, so changePreset(undefined) hit its `if (!preset) return` and did nothing -- silently. A 256x64 design reopened at 128x32 with every element misplaced. importDesign passed no size key at all, same result. Both go through a new applyPresetLabel(), which also handles the custom labels setCustomSize() writes ("200x50"): those are deliberately absent from DISPLAY_PRESETS, so changePreset alone could never round-trip them. Keyboard shortcuts hijacked text fields. The `inInput` guard sat below the Ctrl/Cmd block, under a comment claiming combos "work everywhere". In any input, Ctrl+C copied the selected *element* -- preventDefault stopping the real copy -- Ctrl+V pasted an element, Ctrl+A could not select the field contents, and Tab always moved the element selection, so keyboard users could not reach the next input. Guard moved above both blocks, and it now covers contenteditable too. Resize handles were advertised on five shapes that ignored them. The canvas drew handles for six element types; the editor gated resize and hover on `type === 'rectangle'`. The list was also duplicated inside the canvas. One exported RESIZABLE_TYPES now feeds all four sites. Lines jumped on drag. addElement assigns x/y *before* spreading ELEMENT_DEFAULTS, and the line defaults define only x0/y0 -- so a line carries both, with x at canvas/4 and x0 at 0. Drag and nudge move x0/y0 only, so _getStoredPos preferring `x` handed the drag a base it never updates. Colour-picker edits were lost on reload. onColorChange mutated the element but never set isDirty or called _snapshot, and _debouncedAutosave only runs from _snapshot. applyPaletteColor did both; they match now. Also: section elements drew nothing and reported a 0x0 box, so adding "Section Label" from the palette looked broken and the element was selectable only through the 3px hit-test padding -- they now draw their label, with the bounding box using the same font fallback as the draw call so the two agree. The gauge inset its arc radius by lw/2 where lw is LED pixels and the radius is canvas pixels, then stroked at lw*s, so the arc spilled outside its own bounding box at any scale above 1. And the plugin id is encodeURIComponent'd before it becomes part of a request path. Verified: composer-app.js and composer-canvas.js parse cleanly under tree-sitter (esprima cannot read this codebase -- it predates ??, and fails identically on the unmodified files). Every symbol referenced across module boundaries checked to exist. 156 Python composer tests pass. The static-audit failure is the same 13 classes as before, all defined on main and absent only because this branch is behind; nothing here touches CSS or templates. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- .../static/v3/js/composer/composer-app.js | 82 ++++++++++++++++--- .../static/v3/js/composer/composer-canvas.js | 52 ++++++++++-- 2 files changed, 114 insertions(+), 20 deletions(-) diff --git a/web_interface/static/v3/js/composer/composer-app.js b/web_interface/static/v3/js/composer/composer-app.js index f0124371..92b9c114 100644 --- a/web_interface/static/v3/js/composer/composer-app.js +++ b/web_interface/static/v3/js/composer/composer-app.js @@ -446,8 +446,13 @@ function composerApp() { id: el.id ?? (this._nextId++), })); if (state.dataModel) this.dataModel = state.dataModel; - if (state.currentPreset && state.currentPreset !== this.currentPreset) { - this.changePreset(state.currentPreset, { silent: true }); + // _buildPayload writes this as `preset`; older drafts and hand-edited + // files may carry `currentPreset`. Reading only the latter meant every + // saved design restored onto the default 128x32 canvas, with every + // element then drawn at the wrong place. + const savedPreset = state.preset ?? state.currentPreset; + if (savedPreset && savedPreset !== this.currentPreset) { + this.applyPresetLabel(savedPreset, { silent: true }); } this._nextId = Math.max(...this.elements.map(e => e.id + 1), 1); this.selectedId = null; @@ -455,6 +460,29 @@ function composerApp() { }, // ── Display preset ──────────────────────────────────────────────── + /** + * Restore any saved canvas label, preset or custom. + * + * setCustomSize() stores labels like "200×50" that are deliberately not in + * DISPLAY_PRESETS, so changePreset() alone cannot round-trip them: its + * find() misses and it returns without doing anything, silently. + */ + applyPresetLabel(label, opts = {}) { + if (!label) return; + const known = window.ComposerCanvas.DISPLAY_PRESETS.some(p => p.label === label); + if (known) { this.changePreset(label, opts); return; } + const m = String(label).match(/(\d+)[×xX*,\s]+(\d+)/); + if (!m) return; + const w = Math.max(8, Math.min(512, parseInt(m[1], 10))); + const h = Math.max(8, Math.min(256, parseInt(m[2], 10))); + this.MATRIX_W = w; + this.MATRIX_H = h; + this.currentPreset = `${w}×${h}`; + this.SCALE = w <= 64 ? 6 : w <= 128 ? 4 : 2; + this._applyScale(); + if (!opts.silent) this.render(); + }, + changePreset(presetLabel, opts = {}) { const preset = window.ComposerCanvas.DISPLAY_PRESETS.find(p => p.label === presetLabel); if (!preset) return; @@ -545,7 +573,13 @@ function composerApp() { const data = JSON.parse(ev.target.result); if (!data.composer_version) throw new Error('Not a composer file'); if (this.isDirty && !confirm('Replace current design?')) return; - this._applyState({ metadata: data.metadata, elements: data.elements, dataModel: data.dataModel }); + // Carry the canvas size through too -- an imported 256x64 design + // laid out on a 128x32 canvas puts every element in the wrong place. + this._applyState({ + metadata: data.metadata, elements: data.elements, + dataModel: data.dataModel, + preset: data.preset ?? data.currentPreset, + }); this.isDirty = false; this._setStatus('Design loaded', 'success'); this.render(); @@ -619,7 +653,10 @@ function composerApp() { const { lx, ly } = this._canvasToLed(event); // Priority 1: resize handle on selected rectangle (skip if locked) - if (this.selectedElement?.type === 'rectangle' && !this.selectedElement.locked) { + // Gate on the same list the canvas draws handles from, or the editor + // advertises handles it will not honour. + if (window.ComposerCanvas.RESIZABLE_TYPES.includes(this.selectedElement?.type) + && !this.selectedElement.locked) { const handle = window.ComposerCanvas.getResizeHandle( this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H ); @@ -721,7 +758,7 @@ function composerApp() { const canvas = document.getElementById('led-canvas'); if (canvas) { let cursor = 'crosshair'; - if (this.selectedElement?.type === 'rectangle') { + if (window.ComposerCanvas.RESIZABLE_TYPES.includes(this.selectedElement?.type)) { const handle = window.ComposerCanvas.getResizeHandle( this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H ); @@ -766,6 +803,14 @@ function composerApp() { // Anchor-aware position storage: x/y stored as offset from anchor _getStoredPos(el) { + // addElement() assigns `x`/`y` before spreading ELEMENT_DEFAULTS, and the + // line defaults define only x0/y0 -- so a line carries both, with `x` set + // to canvas/4 and x0 to 0. Drag and nudge move x0/y0 only, so preferring + // `x` here handed the drag a base it never updates and the line jumped by + // the difference on the next grab. + if (el.type === 'line') { + return { x: el.x0 ?? 0, y: el.y0 ?? 0 }; + } return { x: el.x ?? el.x0 ?? 0, y: el.y ?? el.y0 ?? 0 }; }, @@ -817,9 +862,17 @@ function composerApp() { // ── Keyboard shortcuts ──────────────────────────────────────────── _onKeyDown(e) { const tag = document.activeElement?.tagName; - const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; + const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' + || document.activeElement?.isContentEditable === true; + + // While a field has focus the browser's own editing keys win. This + // guard used to sit below, so Ctrl+C copied the selected *element* + // instead of the selected text (preventDefault stopped the real copy), + // Ctrl+V pasted an element, Ctrl+A could not select the field's + // contents, and Tab always moved the element selection rather than + // focus -- leaving no way to reach the next input from the keyboard. + if (inInput) return; - // Ctrl/Cmd combos work everywhere if (e.ctrlKey || e.metaKey) { if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); return; } if ((e.key === 'y') || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); this.redo(); return; } @@ -831,7 +884,7 @@ function composerApp() { if (e.key === 'a') { e.preventDefault(); if (this.elements.length) { this.selectedId = this.elements[0].id; this.render(); } return; } } - // Tab cycles through elements regardless of input focus + // Tab cycles through elements (canvas focus only -- see the guard above) if (e.key === 'Tab' && this.elements.length) { e.preventDefault(); const idx = this.elements.findIndex(el => el.id === this.selectedId); @@ -843,8 +896,6 @@ function composerApp() { return; } - if (inInput) return; - const dist = e.shiftKey ? 5 : (this.snapToGrid && this.snapSize >= 2 ? this.snapSize : 1); if (e.key === 'ArrowLeft') { e.preventDefault(); this.nudge(-dist, 0); } if (e.key === 'ArrowRight') { e.preventDefault(); this.nudge(dist, 0); } @@ -1142,7 +1193,10 @@ function composerApp() { async loadPlugin(pluginId) { this.showOpenModal = false; try { - const resp = await fetch(`/composer/api/load/${pluginId}`); + // encodeURIComponent: this id comes back from /composer/api/plugins, + // and any character outside the expected set would otherwise change + // which path is requested rather than being part of the id. + const resp = await fetch(`/composer/api/load/${encodeURIComponent(pluginId)}`); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.message || `HTTP ${resp.status}`); @@ -1200,6 +1254,12 @@ function composerApp() { else if (hexField === 'empty') { el.emptyR = r; el.emptyG = g; el.emptyB = b; } else { el.r = r; el.g = g; el.b = b; } this._trackColor(r, g, b); + // Matches applyPaletteColor below. Without these, a colour set through + // the picker never marked the design dirty and never took a snapshot -- + // _debouncedAutosave only runs from _snapshot -- so the change was lost + // on reload and could not be undone. + this.isDirty = true; + this._snapshot(); this.render(); }, diff --git a/web_interface/static/v3/js/composer/composer-canvas.js b/web_interface/static/v3/js/composer/composer-canvas.js index adc3791d..5019b81b 100644 --- a/web_interface/static/v3/js/composer/composer-canvas.js +++ b/web_interface/static/v3/js/composer/composer-canvas.js @@ -22,6 +22,13 @@ window.ComposerCanvas = (() => { let _ctx = null; let _showGrid = true; + //: Element types the canvas draws resize handles for. Exported because the + //: editor has to gate its resize and hover behaviour on exactly this list -- + //: the two had drifted, so handles appeared on five shapes that could not + //: actually be resized. + const RESIZABLE_TYPES = ['rectangle', 'rounded_rectangle', 'ellipse', 'arc', + 'gauge', 'sparkline']; + const DISPLAY_PRESETS = [ { label: '64×32', w: 64, h: 32 }, { label: '128×32', w: 128, h: 32 }, @@ -225,8 +232,17 @@ window.ComposerCanvas = (() => { const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2; return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps }; } - case 'section': - return { x: ax, y: ay, w: 0, h: 0 }; + case 'section': { + // Was 0x0, so the element was unselectable except through the 3px + // hit-test padding and drew nothing at all -- a user adding one from + // the palette saw an empty canvas. + // Same font resolution as the draw case below, or the box will not + // match the glyphs: getBoundingBox's shared `finfo` falls back to + // press_start, and a section has no font of its own. + const sinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six; + const label = el.label || 'Section'; + return { x: ax, y: ay, w: label.length * sinfo.charW, h: sinfo.sizePx }; + } default: return { x: ax, y: ay, w: 4, h: 4 }; } @@ -252,7 +268,7 @@ window.ComposerCanvas = (() => { // Returns the handle direction under LED-space point (lx, ly), or null function getResizeHandle(el, lx, ly, matrixW, matrixH) { - if (!['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) return null; + if (!RESIZABLE_TYPES.includes(el.type)) return null; const handles = _getRectHandles(el, matrixW, matrixH); const PAD = 4; for (const [dir, pt] of Object.entries(handles)) { @@ -307,6 +323,18 @@ window.ComposerCanvas = (() => { try { switch (el.type) { + case 'section': { + // A design-time label: it marks a region for the author and is not + // emitted into the generated plugin. There was no case here at all, + // so adding "Section Label" from the palette drew nothing and left + // the user with an apparently broken control. + const sfinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six; + ctx.font = `${sfinfo.sizePx * s}px ${sfinfo.family}`; + ctx.fillStyle = `rgba(${el.r ?? 120},${el.g ?? 120},${el.b ?? 120},0.85)`; + ctx.textBaseline = 'top'; + ctx.fillText(el.label || 'Section', ax * s, ay * s); + break; + } case 'text': case 'dynamic_text': case 'clock': { @@ -496,6 +524,12 @@ window.ComposerCanvas = (() => { const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s; const rx = (gw / 2) * s, ry = (gh / 2) * s; const lw = Math.max(1, (el.lineWidth ?? 3)); + // rx/ry are canvas pixels ((gw/2)*s) but lw is LED pixels, so + // insetting by lw/2 under-corrected by the scale factor while the + // stroke was drawn at lw*s -- the arc spilled outside the element's + // reported bounding box at any SCALE > 1, and the preview stopped + // matching the generated PIL output. + const lwPx = lw * s; const startDeg = el.startAngle ?? 135; const endDeg = el.endAngle ?? 45; // Arc sweep: from startDeg clockwise to endDeg (PIL convention) @@ -510,17 +544,17 @@ window.ComposerCanvas = (() => { // Track arc if (el.hasTrack !== false) { ctx.beginPath(); - ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false); + ctx.ellipse(cx, cy, rx - lwPx / 2, ry - lwPx / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false); ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`; - ctx.lineWidth = lw * s; + ctx.lineWidth = lwPx; ctx.stroke(); } // Fill arc if (pct > 0) { ctx.beginPath(); - ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false); + ctx.ellipse(cx, cy, rx - lwPx / 2, ry - lwPx / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false); ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.lineWidth = lw * s; + ctx.lineWidth = lwPx; ctx.stroke(); } // Centre label @@ -624,7 +658,7 @@ window.ComposerCanvas = (() => { } // Resize handles: on rect, rounded rect, ellipse - if (['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) { + if (RESIZABLE_TYPES.includes(el.type)) { const handles = _getRectHandles(el, matrixW, matrixH); const HS = 5; ctx.fillStyle = 'white'; @@ -752,6 +786,6 @@ window.ComposerCanvas = (() => { init, render, setGrid, updateCanvasSize, hitTest, getBoundingBox, computeActualPos, resolveAnchor, getResizeHandle, getCursorForHandle, - ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS, + ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS, RESIZABLE_TYPES, }; })(); From 732c7d1a30b21c0588f818b025d4ddcf6e660765 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sat, 22 Aug 2026 12:16:44 -0400 Subject: [PATCH 11/15] fix(composer): an element the template cannot draw broke generation Reproduced from the review comment. A `group` element carrying minWidth generated: if width >= 64: # breakpoint: 64px+ displays only # manager.py.j2 wraps each element in the breakpoint and blink blocks, but the body comes from the per-type branches -- and a type with no branch contributes nothing, so the wrapper opens a block with no statements. ast.parse then fails and the caller is told only "Generated code has a syntax error: expected an indented block ... line 49", naming a line of generated source they never see. Two defences: - _preprocess_elements drops types the template has no branch for, alongside the existing `section` skip. This is the root cause: those elements should never have reached the template. - The branch chain ends in `{% else %}pass`, so a type added to the canvas before its drawing branch exists degrades to a no-op rather than a plugin that will not parse. The review also cited dynamic_text with binding_source != 'config'. That one does not reproduce -- the branch emits a draw_text regardless -- which is why an earlier attempt to reproduce this found nothing. _RENDERABLE_ELEMENT_TYPES has to stay in step with the template: a type listed with no branch emits an empty block again, and a branch missing from the list is silently dropped from every generated plugin. A test asserts the two sets are equal rather than trusting them to be maintained together. Tests: 12 new, covering group/unknown/section against breakpoint, blink and both nested, plus the set-equality and fallback checks. 7 fail with both defences reverted. 172 composer tests pass; full suite 3862 passed, the one failure being test_install_lowmem (pre-existing, awaiting #492). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- test/test_composer_empty_block.py | 80 +++++++++++++++++++ web_interface/blueprints/composer.py | 20 +++++ .../templates/v3/composer/manager.py.j2 | 2 + 3 files changed, 102 insertions(+) create mode 100644 test/test_composer_empty_block.py diff --git a/test/test_composer_empty_block.py b/test/test_composer_empty_block.py new file mode 100644 index 00000000..954d391b --- /dev/null +++ b/test/test_composer_empty_block.py @@ -0,0 +1,80 @@ +"""An element the template cannot draw must not produce an empty `if` block. + +manager.py.j2 wraps each element in `if width >= N:` (breakpoint) and/or +`if int(time.time() * 2) % 2:` (blink), and the body comes from the per-type +branches. A type with no branch contributed nothing, so the wrapper opened a +block with no statements in it. ast.parse in _generate_plugin_files then +failed and the caller was told only: + + Generated code has a syntax error: expected an indented block after + 'if' statement on line 49 + +which names a line of generated source the user never sees. Confirmed against +the code before the fix with a `group` element carrying minWidth. + +Two defences, both covered here: _preprocess_elements drops types the template +has no branch for, and the template emits a `pass` fallback so a type added to +the canvas before its branch exists degrades to a no-op instead of a broken +plugin. +""" +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from web_interface.blueprints import composer as C # noqa: E402 + +TEMPLATE = (Path(__file__).resolve().parent.parent + / "web_interface/templates/v3/composer/manager.py.j2") + +BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a", + "version": "1.0.0", "description": "d"} + + +def generate(element): + return C._generate_plugin_files({ + "metadata": BASE_META, + "elements": [element], + "dataModel": {"configVars": []}, + }) + + +@pytest.mark.parametrize("wrapper", [ + {"minWidth": 64}, # breakpoint block + {"blink": True}, # blink block + {"minWidth": 64, "blink": True}, # both, nested +]) +@pytest.mark.parametrize("etype", ["group", "widget_9000", "section"]) +def test_undrawable_element_does_not_break_generation(etype, wrapper): + element = {"type": etype, "x": 0, "y": 0, "color": "#ffffff", **wrapper} + files = generate(element) # must not raise ComposerInputError + assert "manager.py" in files + + +def test_drawable_element_still_renders_inside_a_breakpoint(): + files = generate({"type": "text", "text": "hi", "x": 0, "y": 0, + "minWidth": 64, "color": "#ffffff"}) + src = files["manager.py"] + assert "if width >= 64:" in src + assert "draw_text" in src + + +def test_renderable_types_match_the_template_branches(): + """The constant and the template must agree. + + A type listed in the constant with no branch emits an empty block (the bug + above); a type with a branch but missing from the constant is silently + dropped from every generated plugin. Neither is visible without this check. + """ + branches = set(re.findall(r"el\.type == '([a-z_]+)'", TEMPLATE.read_text())) + assert branches == set(C._RENDERABLE_ELEMENT_TYPES) + + +def test_template_closes_the_branch_chain_with_a_fallback(): + """Belt and braces: even if the constant drifts, no empty block escapes.""" + text = TEMPLATE.read_text() + assert "{% else %}" in text + assert "pass # element type" in text diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py index ea6287b4..4c3f3d78 100644 --- a/web_interface/blueprints/composer.py +++ b/web_interface/blueprints/composer.py @@ -213,6 +213,16 @@ def _aligned_x_expr(x_base_expr: str, text_align: str, char_count: int, char_w: return x_base_expr +#: Element types manager.py.j2 has a drawing branch for. Kept next to the +#: preprocessor because the two must agree: a type here with no branch emits an +#: empty block, and a type with a branch but missing here is silently dropped. +_RENDERABLE_ELEMENT_TYPES = frozenset({ + 'text', 'dynamic_text', 'clock', 'countdown', 'rectangle', 'arc', + 'ellipse', 'pixel', 'rounded_rectangle', 'pips', 'sparkline', 'gauge', + 'marquee', 'progress_bar', +}) + + def _preprocess_elements(elements: list) -> list: """Expand raw element dicts into template-ready dicts with anchor expressions. @@ -231,6 +241,16 @@ def _preprocess_elements(elements: list) -> list: if t == 'section': continue + # A type the template has no branch for still gets its breakpoint and + # blink wrappers emitted, and those would open an `if` with nothing in + # it -- ast.parse then fails and the caller is told only "Generated + # code has a syntax error". Drop it here instead. The template also + # emits a `pass` fallback, so a type added to the canvas before its + # branch exists degrades to a no-op rather than a broken plugin. + if t not in _RENDERABLE_ELEMENT_TYPES: + logger.info("composer: skipping element type %r with no template branch", t) + continue + x_anchor = el.get('xAnchor') or None y_anchor = el.get('yAnchor') or None p['min_width'] = int(el.get('minWidth', 0) or 0) diff --git a/web_interface/templates/v3/composer/manager.py.j2 b/web_interface/templates/v3/composer/manager.py.j2 index 5d90e06d..87d9fa9a 100644 --- a/web_interface/templates/v3/composer/manager.py.j2 +++ b/web_interface/templates/v3/composer/manager.py.j2 @@ -288,6 +288,8 @@ class {{ class_name }}(BasePlugin): {{ pi }} [_pb_x, _pb_y, _pb_x + _pb_fill_w, _pb_y + {{ el.bar_height }}], {{ pi }} fill={{ el.fill_tuple }}, {{ pi }} ) +{% else %} +{{ pi }}pass # element type "{{ el.type }}" draws nothing {% endif %} {% endfor %} # ── End elements ─────────────────────────────────────────────── From 1a0864e5d4f2408af0d13671623b8d581bf85001 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sat, 22 Aug 2026 13:11:47 -0400 Subject: [PATCH 12/15] fix(composer): clamp width/height before they reach generated source Code injection, found by chasing why a security test could not have caught it. _preprocess_elements built the far corner of five shapes by interpolating the payload's width/height straight into generated Python: w = el.get('width', 10) p['x2_expr'] = f"({x_expr}) + {w}" so a rectangle with width='0 or __import__("os").system("id")' generated [0, 0, (0) + 0 or __import__("os").system("id"), (0) + 8], inside a manager.py that /api/install writes to disk and the plugin loader imports and executes. rectangle, arc, ellipse, rounded_rectangle and gauge all share the pattern. Both fields now go through _safe_int, like every other geometry value. Unreachable today only because composer_bp is still unregistered -- the same caveat as the docstring injection fixed earlier in this PR. Why the existing test missed it ------------------------------- test_a_non_numeric_geometry_value_cannot_reach_the_source drove its payloads through a "line" element. manager.py.j2 has never had a `line` branch, so _preprocess_elements produced nothing for it and no value it set could reach the generated source. Every assertion passed trivially, against code that was in fact vulnerable. The test has been vacuous since it was written; the _RENDERABLE_ELEMENT_TYPES constant added in the previous commit only made the cause legible. It now runs across the five types that actually render, over x/y/width/height: 40 of those cases fail with the clamping reverted, where the old version passed 100%. A second test asserts every type used by the injection suite is in _RENDERABLE_ELEMENT_TYPES, so the suite cannot quietly go vacuous again. Also: _payload set "config_vars", but _generate_plugin_files reads data['dataModel']['configVars']. Nothing passed through that key was ever read. Fixed so config-var tests exercise the real path. Full suite: 3967 passed, the one failure being test_install_lowmem (pre-existing, awaiting #492). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- test/test_composer_code_injection.py | 50 ++++++++++++++++++++++++---- web_interface/blueprints/composer.py | 20 +++++------ 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/test/test_composer_code_injection.py b/test/test_composer_code_injection.py index c09e8797..fd022aed 100644 --- a/test/test_composer_code_injection.py +++ b/test/test_composer_code_injection.py @@ -48,7 +48,10 @@ def _payload(**over): - p = {"metadata": dict(BASE_META), "elements": [], "config_vars": []} + # dataModel.configVars is the key _generate_plugin_files reads; "config_vars" + # was never looked at, so anything passed through it tested nothing. + p = {"metadata": dict(BASE_META), "elements": [], + "dataModel": {"configVars": over.pop("config_vars", [])}} p["metadata"].update(over.pop("metadata", {})) p.update(over) return p @@ -77,17 +80,50 @@ def test_a_name_that_breaks_out_of_a_literal_is_refused(payload): _generated(_payload(metadata={"name": payload})) +#: Types with a drawing branch in manager.py.j2. An injection test using any +#: other type proves nothing: _preprocess_elements drops it, so its values +#: never reach the generated source and every assertion passes trivially. +#: This test previously used "line", which has never had a branch. +RENDERED_GEOMETRY_CASES = [ + ("rectangle", {"x": 0, "y": 0, "width": 10, "height": 8}), + ("arc", {"x": 0, "y": 0, "width": 24, "height": 24}), + ("ellipse", {"x": 0, "y": 0, "width": 24, "height": 12}), + ("rounded_rectangle", {"x": 0, "y": 0, "width": 24, "height": 10}), + ("gauge", {"x": 0, "y": 0, "width": 32, "height": 32}), +] + + +@pytest.mark.parametrize("etype,base", RENDERED_GEOMETRY_CASES) @pytest.mark.parametrize("evil", EXPR_PAYLOADS) -@pytest.mark.parametrize("field", ["x", "y", "x0", "y0", "x1", "y1", "lineWidth"]) -def test_a_non_numeric_geometry_value_cannot_reach_the_source(evil, field): - el = {"type": "line", "id": "l1", "x0": 0, "y0": 0, "x1": 10, "y1": 10, - "anchor_x": "right", "anchor_y": "bottom"} +@pytest.mark.parametrize("field", ["x", "y", "width", "height"]) +def test_a_non_numeric_geometry_value_cannot_reach_the_source(etype, base, evil, field): + """width/height were interpolated raw into the generated source. + + p['x2_expr'] = f"({x_expr}) + {w}" with w straight off the payload, so a + rectangle with width='0 or __import__("os").system("id")' produced + + [0, 0, (0) + 0 or __import__("os").system("id"), (0) + 8], + + in a manager.py that /api/install writes to disk and the loader imports. + """ + el = {"type": etype, "id": "e1", **base} el[field] = evil src = _generated(_payload(elements=[el])) - assert "__import__" not in src, f"{field}={evil!r} reached the generated source" + assert "__import__" not in src, f"{etype}.{field}={evil!r} reached the generated source" assert "os.system" not in src assert not _module_level_code(src), \ - f"{field}={evil!r} produced module-level statements: {_module_level_code(src)}" + f"{etype}.{field}={evil!r} produced module-level statements: {_module_level_code(src)}" + + +def test_every_injection_case_uses_a_type_that_actually_renders(): + """Guards against the whole suite quietly going vacuous again. + + An element type with no template branch is dropped before generation, so + an injection test written against one asserts nothing and still passes. + """ + used = {etype for etype, _ in RENDERED_GEOMETRY_CASES} + missing = used - set(C._RENDERABLE_ELEMENT_TYPES) + assert not missing, f"injection tests use non-rendering types: {sorted(missing)}" @pytest.mark.parametrize("evil", EXPR_PAYLOADS) diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py index 4c3f3d78..e122b339 100644 --- a/web_interface/blueprints/composer.py +++ b/web_interface/blueprints/composer.py @@ -310,8 +310,8 @@ def _preprocess_elements(elements: list) -> list: elif t == 'rectangle': x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - w = el.get('width', 10) - h = el.get('height', 8) + w = _safe_int(el.get('width'), 10, 0, 4096) + h = _safe_int(el.get('height'), 8, 0, 4096) p['x_expr'] = x_expr p['y_expr'] = y_expr # x2/y2 as runtime expressions to support anchored positions @@ -372,8 +372,8 @@ def _preprocess_elements(elements: list) -> list: elif t == 'arc': x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - w = el.get('width', 24) - h = el.get('height', 24) + w = _safe_int(el.get('width'), 24, 0, 4096) + h = _safe_int(el.get('height'), 24, 0, 4096) p['x_expr'] = x_expr p['y_expr'] = y_expr p['x2_expr'] = f"({x_expr}) + {w}" @@ -387,8 +387,8 @@ def _preprocess_elements(elements: list) -> list: elif t == 'ellipse': x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - w = el.get('width', 24) - h = el.get('height', 12) + w = _safe_int(el.get('width'), 24, 0, 4096) + h = _safe_int(el.get('height'), 12, 0, 4096) p['x_expr'] = x_expr p['y_expr'] = y_expr p['x2_expr'] = f"({x_expr}) + {w}" @@ -414,8 +414,8 @@ def _preprocess_elements(elements: list) -> list: elif t == 'rounded_rectangle': x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - w = el.get('width', 24) - h = el.get('height', 10) + w = _safe_int(el.get('width'), 24, 0, 4096) + h = _safe_int(el.get('height'), 10, 0, 4096) p['x_expr'] = x_expr p['y_expr'] = y_expr p['x2_expr'] = f"({x_expr}) + {w}" @@ -477,8 +477,8 @@ def _preprocess_elements(elements: list) -> list: elif t == 'gauge': x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') - w = el.get('width', 32) - h = el.get('height', 32) + w = _safe_int(el.get('width'), 32, 0, 4096) + h = _safe_int(el.get('height'), 32, 0, 4096) p['x_expr'] = x_expr p['y_expr'] = y_expr p['x2_expr'] = f"({x_expr}) + {w}" From acc55ef1197e4f36ad13f1eb70cfaa7b9dbaec02 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sat, 22 Aug 2026 13:52:48 -0400 Subject: [PATCH 13/15] fix(composer): scale strokes, anchor lines, snapshot state changes Three review findings in the composer's JavaScript, all confirmed against the code. Stroke widths did not scale with SCALE -------------------------------------- _drawElement scales all geometry by `s`, but left ctx.lineWidth in canvas pixels, so at SCALE>1 every outline rendered thinner than one LED pixel and the preview stopped matching the panel it is previewing. Fixed for rectangle, ellipse, arc, rounded_rectangle, line, divider and progress_bar. Ellipse and arc also inset their radii by half the scaled width -- a stroke straddles its path, so without the inset the outline spills outside the element's bounds. The gauge branch already did this; the rest now match it. Selection handles and the grid stay in canvas pixels deliberately: they are editor chrome, not LED geometry, and live in other functions. `line` ignored anchors ---------------------- _drawElement resolves ax/ay for every element, but the line branch drew raw el.x0/el.y0/el.x1/el.y1. Setting xAnchor or yAnchor moved every other element type and left lines where they were. getBoundingBox had the same omission, so even once a line moved its hit box would not have. Both now translate by (ax - el.x0, ay - el.y0); ax resolves from el.x0 for a line, so that is exactly the anchor offset. Four state mutations skipped _snapshot -------------------------------------- _snapshot serialises metadata and currentPreset and is the only caller of _debouncedAutosave. onBgColorChange, setCustomSize, changePreset and applyPresetLabel each changed exactly those values without calling it, so the background colour and the canvas size were lost on reload and could not be undone. Same defect already fixed in onColorChange. The review named three; applyPresetLabel has it too -- it is the branch that handles sizes absent from DISPLAY_PRESETS. Snapshotting is on the user-driven path only. _applyState and loadTemplate drive these with {silent: true} while restoring, and snapshotting there would push restore steps onto the undo stack and re-autosave the state just loaded. Tests ----- No JS runner here, so test_composer_js_contracts.py asserts on the parse tree via tree-sitter: both files parse, no bare `ctx.lineWidth = 1` inside _drawElement, the line branch and its bounding box carry the anchor offset, each of the five mutations snapshots, and the two preset paths keep their !opts.silent guard ahead of the snapshot. 9 of its 11 checks fail against the previous JS. Full suite 3978 passed, the one failure being test_install_lowmem (pre-existing, awaiting #492). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- test/test_composer_js_contracts.py | 137 ++++++++++++++++++ .../static/v3/js/composer/composer-app.js | 21 ++- .../static/v3/js/composer/composer-canvas.js | 43 ++++-- 3 files changed, 184 insertions(+), 17 deletions(-) create mode 100644 test/test_composer_js_contracts.py diff --git a/test/test_composer_js_contracts.py b/test/test_composer_js_contracts.py new file mode 100644 index 00000000..7e612ee6 --- /dev/null +++ b/test/test_composer_js_contracts.py @@ -0,0 +1,137 @@ +"""Structural checks on the composer's JavaScript. + +There is no JS test runner in this repo, but three defects here are structural +enough to assert on the parse tree, and each was a real bug: + +- Stroke widths inside _drawElement were left in canvas pixels while the + geometry around them scaled by SCALE, so at SCALE>1 every outline rendered + thinner than one LED pixel and the preview stopped matching the panel. +- The `line` branch drew raw el.x0/el.y0/el.x1/el.y1, ignoring the anchor that + every other element type honours, so setting xAnchor moved everything except + lines -- and getBoundingBox had the same omission, leaving the hit box behind. +- Four methods mutated exactly what _snapshot() serialises (metadata, + currentPreset) without calling it. _snapshot is the only caller of + _debouncedAutosave, so those changes were lost on reload and could not be + undone. +""" +import re +from pathlib import Path + +import pytest + +tree_sitter = pytest.importorskip("tree_sitter") +tree_sitter_javascript = pytest.importorskip("tree_sitter_javascript") + +JS_DIR = Path(__file__).resolve().parent.parent / "web_interface/static/v3/js/composer" +CANVAS = JS_DIR / "composer-canvas.js" +APP = JS_DIR / "composer-app.js" + + +def _function_source(path: Path, name: str) -> str: + """Return the source of a top-level function declaration by name.""" + src = path.read_bytes() + lang = tree_sitter.Language(tree_sitter_javascript.language()) + tree = tree_sitter.Parser(lang).parse(src) + found = [] + + def walk(node): + if node.type == "function_declaration": + ident = node.child_by_field_name("name") + if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name: + found.append(src[node.start_byte:node.end_byte].decode()) + for c in node.children: + walk(c) + + walk(tree.root_node) + assert found, f"{name} not found in {path.name}" + return found[0] + + +def _method_source(path: Path, name: str) -> str: + """Return the source of a top-level object method by name.""" + src = path.read_bytes() + lang = tree_sitter.Language(tree_sitter_javascript.language()) + tree = tree_sitter.Parser(lang).parse(src) + found = [] + + def walk(node): + if node.type == "method_definition": + ident = node.child_by_field_name("name") + if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name: + found.append(src[node.start_byte:node.end_byte].decode()) + for c in node.children: + walk(c) + + walk(tree.root_node) + assert found, f"{name} not found in {path.name}" + return found[0] + + +def test_both_files_parse(): + lang = tree_sitter.Language(tree_sitter_javascript.language()) + parser = tree_sitter.Parser(lang) + for path in (CANVAS, APP): + tree = parser.parse(path.read_bytes()) + errors = [] + + def walk(node): + if node.type == "ERROR" or node.is_missing: + errors.append(node.start_point[0] + 1) + for c in node.children: + walk(c) + + walk(tree.root_node) + assert not errors, f"{path.name} has parse errors at lines {errors}" + + +def test_element_strokes_scale_with_scale(): + """No bare `ctx.lineWidth = 1` inside _drawElement. + + Selection handles and the grid are drawn in canvas pixels deliberately and + live in other functions, so this is scoped to the element drawing routine. + """ + body = _function_source(CANVAS, "_drawElement") + offenders = re.findall(r"ctx\.lineWidth\s*=\s*1\s*;", body) + assert not offenders, f"{len(offenders)} unscaled stroke width(s) in _drawElement" + + +def test_line_branch_applies_the_anchor_offset(): + text = CANVAS.read_text() + line_branch = text[text.index("case 'line': {"):] + line_branch = line_branch[:line_branch.index("case 'divider'")] + assert "ax - el.x0" in line_branch and "ay - el.y0" in line_branch, \ + "line drawing ignores xAnchor/yAnchor" + assert "el.x0 * s" not in line_branch, "line still drawn from unanchored endpoints" + + +def test_line_bounding_box_applies_the_anchor_offset(): + text = CANVAS.read_text() + i = text.index("case 'line':", text.index("getBoundingBox")) + box = text[i:i + 400] + assert "ax - el.x0" in box, "line bounding box ignores the anchor" + + +@pytest.mark.parametrize("method", [ + "onBgColorChange", # mutates metadata.bgColor + "setCustomSize", # mutates currentPreset / MATRIX_W / MATRIX_H + "changePreset", # mutates currentPreset / MATRIX_W / MATRIX_H + "applyPresetLabel", # same, for sizes not in DISPLAY_PRESETS + "onColorChange", # the one that was already fixed — keeps it fixed +]) +def test_state_mutations_take_a_snapshot(method): + body = _method_source(APP, method) + assert "_snapshot()" in body, \ + f"{method} changes snapshotted state without calling _snapshot()" + assert "isDirty = true" in body, f"{method} does not mark the design dirty" + + +@pytest.mark.parametrize("method", ["changePreset", "applyPresetLabel"]) +def test_restore_path_stays_snapshot_free(method): + """_applyState and loadTemplate call these with {silent: true} while + restoring; snapshotting there would push restore steps onto the undo stack + and re-autosave the state just loaded.""" + body = _method_source(APP, method) + assert "opts.silent" in body, f"{method} lost its silent guard" + snap = body.index("_snapshot()") + guard = body.index("!opts.silent") + assert guard < snap, f"{method} snapshots outside the !opts.silent guard" diff --git a/web_interface/static/v3/js/composer/composer-app.js b/web_interface/static/v3/js/composer/composer-app.js index 92b9c114..ff49ea41 100644 --- a/web_interface/static/v3/js/composer/composer-app.js +++ b/web_interface/static/v3/js/composer/composer-app.js @@ -480,7 +480,11 @@ function composerApp() { this.currentPreset = `${w}×${h}`; this.SCALE = w <= 64 ? 6 : w <= 128 ? 4 : 2; this._applyScale(); - if (!opts.silent) this.render(); + if (!opts.silent) { + this.isDirty = true; + this._snapshot(); + this.render(); + } }, changePreset(presetLabel, opts = {}) { @@ -496,7 +500,11 @@ function composerApp() { canvas.style.width = (this.MATRIX_W * this.SCALE) + 'px'; canvas.style.height = (this.MATRIX_H * this.SCALE) + 'px'; } - if (!opts.silent) this.render(); + if (!opts.silent) { + this.isDirty = true; + this._snapshot(); + this.render(); + } }, // ── Rendering ───────────────────────────────────────────────────── @@ -626,6 +634,10 @@ function composerApp() { this.currentPreset = `${w}×${h}`; this.SCALE = w <= 64 ? 6 : w <= 128 ? 4 : 2; this._applyScale(); + // currentPreset is part of the snapshot; a custom size set here was + // otherwise dropped on reload and could not be undone. + this.isDirty = true; + this._snapshot(); this._setStatus(`Canvas set to ${w}×${h}`, 'info'); }, zoomFit() { @@ -645,6 +657,11 @@ function composerApp() { g: parseInt(hex.slice(3, 5), 16), b: parseInt(hex.slice(5, 7), 16), }; + // _snapshot serialises metadata and is the only caller of + // _debouncedAutosave, so without this the background colour was lost on + // reload and could not be undone. Same defect as onColorChange. + this.isDirty = true; + this._snapshot(); this.render(); }, diff --git a/web_interface/static/v3/js/composer/composer-canvas.js b/web_interface/static/v3/js/composer/composer-canvas.js index 5019b81b..b07c26b8 100644 --- a/web_interface/static/v3/js/composer/composer-canvas.js +++ b/web_interface/static/v3/js/composer/composer-canvas.js @@ -208,12 +208,16 @@ window.ComposerCanvas = (() => { return { x: ax, y: ay, w: el.width, h: el.height }; case 'pixel': return { x: ax, y: ay, w: 1, h: 1 }; - case 'line': + case 'line': { + // Same anchor offset the draw path applies, or the hit box sits where + // the line used to be. + const dx = ax - el.x0, dy = ay - el.y0; return { - x: Math.min(el.x0, el.x1), y: Math.min(el.y0, el.y1), + x: Math.min(el.x0, el.x1) + dx, y: Math.min(el.y0, el.y1) + dy, w: Math.max(1, Math.abs(el.x1 - el.x0)), h: Math.max(1, Math.abs(el.y1 - el.y0)), }; + } case 'divider': return el.orientation === 'horizontal' ? { x: 0, y: ay, w: matrixW, h: 1 } @@ -384,7 +388,9 @@ window.ComposerCanvas = (() => { } if (el.hasOutline) { ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`; - ctx.lineWidth = 1; + // 1 LED pixel, not 1 canvas pixel: at SCALE>1 an unscaled stroke + // renders thinner than the geometry it outlines. + ctx.lineWidth = s; ctx.strokeRect(rx, ry, rw, rh); } break; @@ -393,8 +399,11 @@ window.ComposerCanvas = (() => { case 'ellipse': { const cx = (ax + el.width / 2) * s; const cy = (ay + el.height / 2) * s; - const rx = (el.width / 2) * s; - const ry = (el.height / 2) * s; + const lwPx = s; // 1 LED pixel + // Inset by half the stroke, which straddles the path, so the outline + // stays within the element's bounds. + const rx = Math.max(0, (el.width / 2) * s - (el.hasOutline ? lwPx / 2 : 0)); + const ry = Math.max(0, (el.height / 2) * s - (el.hasOutline ? lwPx / 2 : 0)); ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); if (el.hasFill) { @@ -403,7 +412,7 @@ window.ComposerCanvas = (() => { } if (el.hasOutline) { ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`; - ctx.lineWidth = 1; + ctx.lineWidth = lwPx; ctx.stroke(); } break; @@ -412,15 +421,16 @@ window.ComposerCanvas = (() => { case 'arc': { const cx = (ax + el.width / 2) * s; const cy = (ay + el.height / 2) * s; - const rx = (el.width / 2) * s; - const ry = (el.height / 2) * s; + const lwPx = Math.max(1, el.lineWidth || 2) * s; + const rx = Math.max(0, (el.width / 2) * s - lwPx / 2); + const ry = Math.max(0, (el.height / 2) * s - lwPx / 2); // PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false const startRad = (el.startAngle ?? 0) * Math.PI / 180; const endRad = (el.endAngle ?? 270) * Math.PI / 180; ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false); ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.lineWidth = Math.max(1, el.lineWidth || 2); + ctx.lineWidth = lwPx; ctx.stroke(); break; } @@ -443,18 +453,21 @@ window.ComposerCanvas = (() => { } if (el.hasOutline) { ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`; - ctx.lineWidth = 1; + ctx.lineWidth = s; ctx.stroke(); } break; } case 'line': { + // ax/ay resolve from el.x0 for a line, so this is the anchor offset. + // Without it a line stayed put while every other type moved. + const dx = ax - el.x0, dy = ay - el.y0; ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.lineWidth = Math.max(1, el.lineWidth || 1); + ctx.lineWidth = Math.max(1, el.lineWidth || 1) * s; ctx.beginPath(); - ctx.moveTo(el.x0 * s, el.y0 * s); - ctx.lineTo(el.x1 * s, el.y1 * s); + ctx.moveTo((el.x0 + dx) * s, (el.y0 + dy) * s); + ctx.lineTo((el.x1 + dx) * s, (el.y1 + dy) * s); ctx.stroke(); break; } @@ -462,7 +475,7 @@ window.ComposerCanvas = (() => { case 'divider': { const isH = (el.orientation || 'horizontal') === 'horizontal'; ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.lineWidth = 1; + ctx.lineWidth = s; ctx.beginPath(); if (isH) { ctx.moveTo(0, ay * s + 0.5); @@ -614,7 +627,7 @@ window.ComposerCanvas = (() => { } if (el.hasOutline) { ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`; - ctx.lineWidth = 1; + ctx.lineWidth = s; ctx.strokeRect(rx, ry, bw * s, bh * s); } break; From 37fc1b56b5ee4f931a38562ea4c4af1b51d60697 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sat, 22 Aug 2026 14:35:30 -0400 Subject: [PATCH 14/15] fix(composer): coerce prefixed colour channels, non-finite numbers, marquee ids Three more routes into the generated source, plus a fix to one of my own tests that was checking the wrong branch. Prefixed colour channels were interpolated raw ---------------------------------------------- Five tuples were built without coercion: p['fill_tuple'] = f"({el.get('r', 100)}, {el.get('g', 200)}, ...)" p['empty_tuple'] = f"({el.get('emptyR', 50)}, ...)" p['label_tuple'] = f"({el.get('labelR', 200)}, ...)" so progress_bar, pips, sparkline and gauge took arbitrary expressions the same way width/height did. Confirmed: every one of the five put __import__ into the generated source. They now go through a new _rgb_tuple helper, which _rgb_expr also delegates to. The pre-existing colour test only covered r/g/b on a text element, which is why the prefixed channels and these four types were never exercised. Non-finite numbers escaped as a 500 ----------------------------------- json.loads accepts Infinity/-Infinity/NaN by default and Flask's get_json passes them straight through, so a payload can hand _safe_int a non-finite float. int(inf) raises OverflowError, which is neither ValueError nor ComposerInputError, so it escaped both handlers and surfaced as a 500 with a traceback rather than a 422. Verified end to end through Flask's parser. Marquee ids reached the source as identifiers --------------------------------------------- data_key is spliced UNQUOTED into variable names (_{{ data_key }}_text = ...) and only '-' was normalised. A punctuated id landed in the generated source as code. ast.parse caught it, so this was not exploitable, but the caller got an opaque "Generated code has a syntax error" instead of being told the id was unusable -- the same failure mode as the empty-block bug. Now restricted to identifier characters and bounded to 64. The line-anchor test was testing the wrong branch ------------------------------------------------- test_line_branch_applies_the_anchor_offset searched the whole file for "case 'line': {". getBoundingBox has one too and comes first, so the assertion was reading the bounding-box branch: stripping the anchor offset from _drawElement left all 11 checks green. Both line tests are now scoped to their own function via tree-sitter, so they cannot be satisfied by the same branch. Tests: 35 of the injection suite's checks fail against the reverted fixes; the scoped line test fails when _drawElement's offset is removed. Full suite 4059 passed, the one failure being test_install_lowmem (pre-existing, awaiting #492). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- test/test_composer_code_injection.py | 68 ++++++++++++++++++++++++++++ test/test_composer_js_contracts.py | 22 ++++++--- web_interface/blueprints/composer.py | 49 +++++++++++++++----- 3 files changed, 121 insertions(+), 18 deletions(-) diff --git a/test/test_composer_code_injection.py b/test/test_composer_code_injection.py index fd022aed..a9527098 100644 --- a/test/test_composer_code_injection.py +++ b/test/test_composer_code_injection.py @@ -17,6 +17,7 @@ -> f-string interpolated it verbatim: x=0 or __import__("os").system("id") """ import ast +import re import sys from pathlib import Path @@ -231,3 +232,70 @@ def test_a_clock_without_a_format_uses_the_documented_default(): el = {"type": "clock", "id": "c1", "x": 0, "y": 0, "font": "press_start"} src = _generated(_payload(elements=[el])) assert '"%H:%M"' in src, "the %H:%M default did not reach the generated source" + + +#: (element type, channel key, base element) for colour channels that were +#: interpolated raw rather than through _rgb_expr/_safe_int. Prefixed channels +#: (emptyR/G/B, labelR/G/B) were the ones the original r/g/b test never reached. +RAW_COLOUR_CASES = [ + ("progress_bar", "r", {"x": 0, "y": 0}), + ("progress_bar", "g", {"x": 0, "y": 0}), + ("pips", "b", {"x": 0, "y": 0}), + ("pips", "emptyR", {"x": 0, "y": 0}), + ("pips", "emptyG", {"x": 0, "y": 0}), + ("sparkline", "r", {"x": 0, "y": 0}), + ("gauge", "labelR", {"x": 0, "y": 0, "width": 32, "height": 32}), + ("gauge", "labelB", {"x": 0, "y": 0, "width": 32, "height": 32}), +] + + +@pytest.mark.parametrize("etype,channel,base", RAW_COLOUR_CASES) +@pytest.mark.parametrize("evil", EXPR_PAYLOADS) +def test_a_prefixed_colour_channel_cannot_reach_the_source(etype, channel, base, evil): + """Five tuples were built with f"({el.get('r', 100)}, ...)" -- no coercion. + + The pre-existing colour test only covered r/g/b on a text element, so the + prefixed channels and the four other types were never exercised. + """ + el = {"type": etype, "id": "e1", **base} + el[channel] = evil + src = _generated(_payload(elements=[el])) + assert "__import__" not in src, f"{etype}.{channel}={evil!r} reached the source" + assert "os.system" not in src + assert not _module_level_code(src) + + +@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan")]) +@pytest.mark.parametrize("field", ["x", "y", "width", "height"]) +def test_a_non_finite_dimension_does_not_escape_as_an_unhandled_error(field, value): + """json.loads accepts Infinity/NaN and Flask passes them through, so a + payload can hand _safe_int a non-finite float. int(inf) raises + OverflowError -- neither ValueError nor ComposerInputError -- so it escaped + both handlers and surfaced as a 500 with a traceback instead of a 422.""" + el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8} + el[field] = value + src = _generated(_payload(elements=[el])) # must not raise + # A non-finite value must be replaced by the default, not spelled into the + # source. Word-boundary match: "info" in self.logger.info contains "inf". + assert not re.search(r"\b(inf|nan|Infinity|NaN)\b", src), \ + f"{field}={value!r} leaked a non-finite literal into the source" + assert not _module_level_code(src) + + +@pytest.mark.parametrize("bad_id", [ + 'x = __import__("os").system("id") #', + "x\nimport os\n_y", + "x[0]", + "", + "a" * 200, +]) +def test_a_marquee_id_cannot_become_code(bad_id): + """data_key is spliced UNQUOTED into variable names + (_{{ data_key }}_text = ...), so a non-identifier id landed in the source + as code. ast.parse caught it, but the caller then got an opaque + "Generated code has a syntax error" rather than being told the id is bad.""" + el = {"type": "marquee", "id": bad_id, "x": 0, "y": 0, "text": "hi"} + src = _generated(_payload(elements=[el])) # must not raise + assert "__import__(" not in src + assert "os.system(" not in src + assert not _module_level_code(src) diff --git a/test/test_composer_js_contracts.py b/test/test_composer_js_contracts.py index 7e612ee6..202e11b1 100644 --- a/test/test_composer_js_contracts.py +++ b/test/test_composer_js_contracts.py @@ -96,18 +96,28 @@ def test_element_strokes_scale_with_scale(): def test_line_branch_applies_the_anchor_offset(): - text = CANVAS.read_text() - line_branch = text[text.index("case 'line': {"):] + """Scoped to _drawElement. + + getBoundingBox has its own `case 'line': {` and appears first in the file, + so searching the whole text found *that* branch -- this assertion passed + with the draw branch's anchor offset removed. Verified: stripping it and + re-running gave 11/11 green. + """ + body = _function_source(CANVAS, "_drawElement") + line_branch = body[body.index("case 'line': {"):] line_branch = line_branch[:line_branch.index("case 'divider'")] assert "ax - el.x0" in line_branch and "ay - el.y0" in line_branch, \ "line drawing ignores xAnchor/yAnchor" - assert "el.x0 * s" not in line_branch, "line still drawn from unanchored endpoints" + assert "moveTo(el.x0 * s" not in line_branch, \ + "line still drawn from unanchored endpoints" def test_line_bounding_box_applies_the_anchor_offset(): - text = CANVAS.read_text() - i = text.index("case 'line':", text.index("getBoundingBox")) - box = text[i:i + 400] + """The companion to the above: scoped to getBoundingBox specifically, so + the two tests cannot both be satisfied by the same branch.""" + body = _function_source(CANVAS, "getBoundingBox") + box = body[body.index("case 'line'"):] + box = box[:box.index("case 'divider'")] assert "ax - el.x0" in box, "line bounding box ignores the anchor" diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py index e122b339..843e620a 100644 --- a/web_interface/blueprints/composer.py +++ b/web_interface/blueprints/composer.py @@ -11,6 +11,7 @@ import ast import io import json +import math import keyword import logging import os @@ -150,9 +151,16 @@ def _safe_int(value, default: int = 0, lo: int | None = None, produced `x=0 or __import__("os").system("id")` in the generated source, which is valid Python and so passed the ast.parse check. """ + # json.loads accepts Infinity/-Infinity/NaN by default and Flask's + # get_json passes them straight through, so a payload can hand this a + # non-finite float. int(inf) raises OverflowError, which is neither + # ValueError nor ComposerInputError -- it escaped both handlers and became + # a 500 with a traceback instead of a 422. + if isinstance(value, float) and not math.isfinite(value): + return default try: out = int(value) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): return default if lo is not None: out = max(lo, out) @@ -161,11 +169,22 @@ def _safe_int(value, default: int = 0, lo: int | None = None, return out +def _rgb_tuple(el: dict, keys, defaults) -> str: + """A colour tuple literal from coerced, clamped values under *keys*. + + Several element types carry prefixed channels (emptyR/G/B, labelR/G/B) + rather than r/g/b. Those were interpolated straight into the generated + source, so they were an injection route exactly like an uncoerced + dimension. Every channel now goes through _safe_int. + """ + return "(" + ", ".join( + str(_safe_int(el.get(k), d, 0, 255)) for k, d in zip(keys, defaults) + ) + ")" + + def _rgb_expr(el: dict, dr: int = 255, dg: int = 255, db: int = 255) -> str: """A colour tuple literal built from coerced, clamped channel values.""" - return (f"({_safe_int(el.get('r'), dr, 0, 255)}, " - f"{_safe_int(el.get('g'), dg, 0, 255)}, " - f"{_safe_int(el.get('b'), db, 0, 255)})") + return _rgb_tuple(el, ('r', 'g', 'b'), (dr, dg, db)) def _compute_pos_expr(val, anchor: str | None, dim_var: str) -> str: @@ -356,7 +375,7 @@ def _preprocess_elements(elements: list) -> list: p['bar_height'] = int(el.get('barHeight', 6)) binding = el.get('binding', {}) p['binding_key'] = binding.get('key', '') - p['fill_tuple'] = f"({el.get('r', 100)}, {el.get('g', 200)}, {el.get('b', 100)})" + p['fill_tuple'] = _rgb_tuple(el, ('r', 'g', 'b'), (100, 200, 100)) bg = ( [el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)] if el.get('hasBg', True) else None @@ -454,8 +473,8 @@ def _preprocess_elements(elements: list) -> list: p['show_empty'] = bool(el.get('showEmpty', True)) binding = el.get('binding', {}) p['binding_key'] = binding.get('key', '') - p['fill_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})" - p['empty_tuple'] = f"({el.get('emptyR', 50)}, {el.get('emptyG', 50)}, {el.get('emptyB', 50)})" + p['fill_tuple'] = _rgb_tuple(el, ('r', 'g', 'b'), (255, 200, 0)) + p['empty_tuple'] = _rgb_tuple(el, ('emptyR', 'emptyG', 'emptyB'), (50, 50, 50)) p['blink'] = bool(el.get('blink', False)) elif t == 'sparkline': @@ -469,7 +488,7 @@ def _preprocess_elements(elements: list) -> list: p['bar_spacing'] = max(0, int(el.get('barSpacing', 1))) binding = el.get('binding', {}) p['binding_key'] = binding.get('key', '') - p['fill_tuple'] = f"({el.get('r', 80)}, {el.get('g', 200)}, {el.get('b', 120)})" + p['fill_tuple'] = _rgb_tuple(el, ('r', 'g', 'b'), (80, 200, 120)) bg = [el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)] if el.get('hasBg', False) else None p['bg_tuple'] = _as_fill_filter(bg) p['blink'] = bool(el.get('blink', False)) @@ -497,7 +516,7 @@ def _preprocess_elements(elements: list) -> list: font_key = el.get('font', 'four_by_six') p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font') p['show_label'] = bool(el.get('showLabel', True)) - p['label_tuple'] = f"({el.get('labelR', 200)}, {el.get('labelG', 200)}, {el.get('labelB', 200)})" + p['label_tuple'] = _rgb_tuple(el, ('labelR', 'labelG', 'labelB'), (200, 200, 200)) p['blink'] = bool(el.get('blink', False)) elif t == 'marquee': @@ -510,9 +529,15 @@ def _preprocess_elements(elements: list) -> list: p['gap'] = int(el.get('gap', 16)) p['scroll_speed'] = max(1, int(el.get('scrollSpeed', 1))) p['direction'] = el.get('direction', 'left') - # Data key stored in self._data for stateful scrolling across display() calls - raw_id = str(el.get('id', 0)).replace('-', '_') - p['data_key'] = f"mq_{raw_id}" + # Data key stored in self._data for stateful scrolling across + # display() calls. It is spliced UNQUOTED into variable names + # (_{{ data_key }}_text = ...), so anything that is not an + # identifier character lands in the generated source as code. + # ast.parse catches the result, but the caller then gets an opaque + # "Generated code has a syntax error" instead of being told the id + # is unusable. Restrict it to identifier characters and bound it. + raw_id = re.sub(r'[^A-Za-z0-9_]', '_', str(el.get('id', 0)))[:64] + p['data_key'] = f"mq_{raw_id or '0'}" p['blink'] = bool(el.get('blink', False)) result.append(p) From 2f42d179f68f2b4782b72bc8393a69fd0734a97d Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sat, 22 Aug 2026 15:13:27 -0400 Subject: [PATCH 15/15] fix(composer): point the align toolbar at the anchor-clearing path Two alignment implementations existed and the toolbar used the wrong one. alignElement(dir) set el.x/el.y and stopped there. resolveAnchor turns anchor='right' into `dim - val`, so with xAnchor='right' an "align left" (el.x = 0) resolved to x = MATRIX_W and the element jumped to the far right edge -- the opposite of what was asked. It also never touched el.x0/el.y0, so a line's endpoints were left where they were. _alignElement already did both correctly: it clears the anchor so the stored value is absolute, and moves x0/y0 for lines. Its six wrappers -- alignLeft, alignHCenter, alignRight, alignTop, alignVCenter, alignBottom -- existed and had no callers at all. All six toolbar buttons now call the wrappers, and the legacy method is removed rather than left to drift back into use. Tests: the toolbar calls each wrapper and no longer calls alignElement, the legacy definition is gone, and _alignElement still clears the anchor and moves line endpoints. Two of them fail against the previous markup. Full suite 4062 passed, the one failure being test_install_lowmem (pre-existing, awaiting #492). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- test/test_composer_js_contracts.py | 40 +++++++++++++++++++ .../static/v3/js/composer/composer-app.js | 17 -------- web_interface/templates/v3/composer.html | 12 +++--- 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/test/test_composer_js_contracts.py b/test/test_composer_js_contracts.py index 202e11b1..eb08e0de 100644 --- a/test/test_composer_js_contracts.py +++ b/test/test_composer_js_contracts.py @@ -145,3 +145,43 @@ def test_restore_path_stays_snapshot_free(method): snap = body.index("_snapshot()") guard = body.index("!opts.silent") assert guard < snap, f"{method} snapshots outside the !opts.silent guard" + + +TEMPLATE_HTML = (Path(__file__).resolve().parent.parent + / "web_interface/templates/v3/composer.html") + +#: The six toolbar buttons and the wrapper each must call. +ALIGN_BUTTONS = ["alignLeft", "alignHCenter", "alignRight", + "alignTop", "alignVCenter", "alignBottom"] + + +def test_alignment_buttons_use_the_anchor_clearing_path(): + """Two alignment implementations existed and the toolbar used the wrong one. + + The legacy alignElement(dir) set el.x/el.y but left xAnchor/yAnchor in + place. resolveAnchor turns anchor='right' into `dim - val`, so "align left" + (el.x = 0) resolved to x = MATRIX_W -- the element jumped to the far right + edge instead. _alignElement clears the anchor first, so the stored value is + absolute, and it also updates el.x0/el.y0 so lines actually move. + """ + html = TEMPLATE_HTML.read_text() + for wrapper in ALIGN_BUTTONS: + assert f"{wrapper}()" in html, f"toolbar does not call {wrapper}()" + assert not re.search(r"[^_]alignElement\(", html), \ + "toolbar still calls the legacy alignElement()" + + +def test_the_legacy_alignelement_is_gone(): + """Leaving it in place invites the toolbar drifting back to it.""" + src = APP.read_text() + assert not re.search(r"^\s{4}alignElement\(dir\)", src, re.M), \ + "legacy alignElement(dir) still defined" + + +def test_align_clears_the_anchor_and_moves_line_endpoints(): + body = _method_source(APP, "_alignElement") + assert "xAnchor = null" in body and "yAnchor = null" in body, \ + "_alignElement no longer clears the anchor, so aligning an anchored " \ + "element resolves to the wrong edge" + assert "el.x0" in body and "el.y0" in body, \ + "_alignElement no longer moves line endpoints" diff --git a/web_interface/static/v3/js/composer/composer-app.js b/web_interface/static/v3/js/composer/composer-app.js index ff49ea41..01e6767f 100644 --- a/web_interface/static/v3/js/composer/composer-app.js +++ b/web_interface/static/v3/js/composer/composer-app.js @@ -1098,23 +1098,6 @@ function composerApp() { }, // ── Alignment ───────────────────────────────────────────────────── - alignElement(dir) { - const el = this.selectedElement; - if (!el) return; - const bb = window.ComposerCanvas.getBoundingBox(el, this.MATRIX_W, this.MATRIX_H); - switch (dir) { - case 'left': el.x = 0; break; - case 'center': el.x = Math.round((this.MATRIX_W - bb.w) / 2); break; - case 'right': el.x = this.MATRIX_W - bb.w; break; - case 'top': el.y = 0; break; - case 'middle': el.y = Math.round((this.MATRIX_H - bb.h) / 2); break; - case 'bottom': el.y = this.MATRIX_H - bb.h; break; - } - this.isDirty = true; - this._snapshot(); - this.render(); - }, - // ── Templates ───────────────────────────────────────────────────── loadTemplate(tmpl) { if (this.isDirty && this.elements.length > 0) { diff --git a/web_interface/templates/v3/composer.html b/web_interface/templates/v3/composer.html index b6b062a8..053c316b 100644 --- a/web_interface/templates/v3/composer.html +++ b/web_interface/templates/v3/composer.html @@ -667,13 +667,13 @@

Align to Canvas
- - - + + +
- - - + + +