mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
feat: adaptive layout system — size-aware regions, crisp font ladders, image fitting (#393)
* feat(layout): adaptive layout & font scaling system for plugins Add src/adaptive_layout.py — opt-in core helpers so plugins render legibly on any panel size without hand-tuned per-display layouts: - Region: integer rect algebra (bands/columns/weighted splits/centering) that partitions space so text bands can't overlap by construction - Font ladders: ordered (family, size) steps known to render crisply (LADDER_GRID: X11 BDFs at native sizes; LADDER_ARCADE: PressStart2P at 8px multiples) — fitting walks the ladder instead of scaling pixel fonts fractionally - LayoutContext: breakpoint tiers, geometry scale vs. a declared design size, and cached fit_text/fit_lines/font_for_rows queries Generalizes the three patterns proven in the field: f1-scoreboard's scale factor, masters-tournament's tiers, baseball-scoreboard's font fallback ladder. Wiring: BasePlugin gains a lazy .layout property and draw_fit(); FontManager gains get_native_bdf_size() and a cache_generation counter; manifest schema gains display.design_size and requires.display_size max_width/max_height; 96x48 joins DEFAULT_TEST_SIZES; the bounds-check harness records negative-coordinate draws; TextHelper's broken measurement helpers are fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): adaptive image fitting + composite region helpers Add src/adaptive_images.py — the image counterpart to fit_text: - fit_image(img, box, mode=contain|cover|fill_height|stretch, crop_to_ink, anchor, resample, upscale) promoting the proven plugin patterns (football's crop-to-ink fill-height logos, masters' cover crop + NEAREST flags, static-image's letterbox). Upscales by default — thumbnail()'s downscale-only behavior is why imagery stays tiny on big panels. - draw_fitted_image() pastes aligned within a Region with alpha mask. - One central Pillow>=9.1 RESAMPLE shim replacing ~15 plugin copies. LayoutContext.fit_image() caches results per (identity, box size, options) with a 64-entry LRU; id()-keyed entries pin the source image. BasePlugin.draw_image() is the one-liner adoption path beside draw_fit. Composites in adaptive_layout.py: Region.offset() (user x/y-offset passthrough), scoreboard_regions() (the two-logos-plus-score card math duplicated across six sports plugins, logo_slot = min(H, W//2)), and media_row() (art-left/text-right). Fix LogoHelper's size-blind cache key (stale sizes on panel change); deprecation note on dead image_utils.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(harness): scale-up fill check, config variants, multi-size dev gallery Quality gates for adaptive layout: - fill_metrics()/check_scale_up() in the safety harness: overflow catches content too big for a panel, but nothing caught content that stays tiny on panels >= 2x the plugin's declared design size. The check measures lit-content extents and warns (or fails, when a plugin opts into "fill_check": "strict" in test/harness.json) below 50% coverage on the doubled axis. Warn-only by default so no existing plugin breaks. - harness.json "variants": extra runs with config overlays and their own golden dirs, so an opt-in mode (e.g. layout_mode: adaptive) is golden- tested beside the classic default. check_plugin.py loops base + variants and labels variant results mode@name. - Dev preview server: GET /api/sizes (harness size sample), POST /api/render-matrix (render at up to 12 sizes in one call), size-preset dropdown, and an "All Sizes" side-by-side gallery in the preview UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plugins): adaptive-lib discoverability + advisory version compat warning Discoverability: re-export the adaptive layout/image API from src.common (the blessed-helpers package plugin authors already know) — canonical paths stay src.adaptive_layout / src.adaptive_images so nothing breaks. Document it in src/common/README.md and cross-link ADAPTIVE_LAYOUT.md from the developer docs authors actually read (quick reference, API reference, advanced dev, font manager, dev preview, plugin dev guide); ADAPTIVE_LAYOUT.md gains adaptive-images, composite-layouts and preserving-user-customization sections. Compat: PluginLoader now logs one advisory warning (never raises) when a plugin's manifest declares a min LEDMatrix version newer than the running core, checking the min_ledmatrix_version / requires.* / versions[] spellings found in the wild. Guarded against stale core version numbers. src/__init__.py __version__ bumped 1.0.0 -> 3.1.0 to match the latest release tag (v3.1.0) — it had never been updated and the compat check needs a truthful number. NOTE: verify this matches the intended release numbering before the next tag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): add measure_font_crispness — verify a ladder rung isn't blurry PIL antialiases TTF outlines by default; a 'pixel-style' font only rasterizes without antialiasing at specific sizes (for PressStart2P: exact multiples of its 8px design grid). A ladder rung at an unverified size silently renders blurry on an LED panel — this exact bug shipped in both text-display's and football-scoreboard's custom TTF ladders (non-8-multiple PressStart2P sizes, and '5by7.regular'/'4x6-font' at sizes that were never actually crisp). measure_font_crispness(font, sample_text) renders the sample and reports the fraction of ink-bbox pixels that are neither pure black nor pure white. BDF fonts (real bitmaps) always score 0.0; TTF ladders should be verified against this before shipping — see the new TestFontFitting::test_ladder_arcade_is_crisp pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): add fit_text_proportional — proportional sizing vs. always-maximize fit_text always picks the largest ladder rung that fits its box. That's right when an element owns dedicated space, but wrong when several independently-fitted elements need to stay visually harmonious as the panel grows: a score's box might have generous room while a neighboring logo scales by a fixed geometry factor via px() — fit_text lets the score balloon out of proportion (even overlapping the logo) even though its individual pick is technically correct. fit_text_proportional(text, box, base_size_px, ladder) instead targets base_size_px * self.scale (the same scale factor px() already uses), picking the nearest ladder rung at or below that target, still capped to what fits the box, floored at the smallest rung when the target is below every rung. Refactored the shared largest-that-fits/ellipsize walk into _walk_ladder() so fit_text and fit_text_proportional don't duplicate it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): fit_text_proportional gains an axis-specific scale override self.scale (min(width_ratio, height_ratio)) is the right conservative default for anything whose aspect ratio matters, but a caller whose surrounding composition already scales along a single axis — e.g. football-scoreboard's logo_slot = min(height, width // 2), which tracks height alone — needs text sized the same way, or it reads as under-scaled next to logos that grew on a panel that only got taller (128x32 -> 128x64: self.scale stays 1.0 since width didn't grow, but logos still double). fit_text_proportional(..., scale=None) now accepts an explicit override; None keeps the existing self.scale default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(layout): scoreboard_regions reserves real center space at 2:1 aspect ratios logo_slot = min(height, width // 2) has a blind spot: at exactly 2:1 aspect ratio (width == 2 * height -- a very common shape: two, four, or more square modules stacked into a taller panel) width // 2 and height are equal, so the two logo slots claim the ENTIRE width and leave zero pixels for a center column, no matter how large the panel gets. Not a 'small panel' problem -- 96x48, 128x64, and 256x128 (all exactly 2:1) hit it identically, while the 128x32 design baseline and panels like 192x48 or 256x32 never do, because height is already the tighter constraint there. Two new parameters fix it in the one shared helper every scoreboard-style plugin composes through: - min_center_fraction / min_center_design_px reserve at least max(width * fraction, design_px * ctx.scale) for the center column, capping logo_slot further when needed. The scaled design-px term matters on small panels where a flat fraction alone reserves too little absolute space. - score_bleed_fraction extends the score's own fit box (not the logo slots themselves) a controlled amount into each side -- the same way real broadcast scoreboards let a big score number's edges cross into the team marks flanking it. Without this the reserve alone can still be too narrow for a short score to render without truncating. score_area is now genuinely narrower than the full card width (previously identical to status_band/detail_band, which still span the full width and overlay the logos -- short text there was never the problem). Verified against the full harness size spread: a real game score like '17-21' never needs ellipsis at any tested 2:1-or-tighter aspect ratio (test_score_never_needs_ellipsis_for_a_short_score), and wide panels (128x32/192x48/256x32-style) are provably unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document scoreboard_regions' center-reserve and score-bleed params Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address CodeRabbit review on PR #393 - docs: scope the self.layout note to BasePlugin subclasses (others build a LayoutContext directly) and make explicit that adaptive layout is opt-in — classic rendering stays unless a plugin adopts the APIs. - dev_server: broaden the render-request catch (a bad manifest.json now returns a clean 400 instead of an unhandled 500) and stop echoing raw exception text in the loader-failure responses — full tracebacks go to the dev server's console log instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): allowlist plugin_id before any path lookup CodeQL (py/path-injection): plugin_id arrives in request input and flows into filesystem paths via find_plugin_dir. Gate it with the same ^[a-zA-Z0-9_-]{1,64}$ allowlist the web UI's pages_v3 uses, at the single choke point every route resolves through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): lexical containment check on resolved plugin dirs CodeQL doesn't recognize the interprocedural allowlist as a path-injection barrier; add the canonical one — normalize (without following symlinks, since dev plugins are commonly symlinked into plugins/) and require the result to stay inside the search dir. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): inline normpath containment barrier before render CodeQL doesn't credit the sanitization inside find_plugin_dir along this flow; apply its documented barrier (normpath + startswith against the allowed roots) inline in _parse_render_request, on the exact path that reaches the render/load sinks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): derive plugin dir from trusted directory listings CodeQL's barrier-guard recognition doesn't see a startswith check inside an any() comprehension, so the normalize-and-prefix approach still flagged. Break the taint outright instead: after lookup, re-derive the directory by enumerating the search dirs (iterdir) and matching by path equality — the Path used for all downstream file access is built solely from trusted listings, never from request input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): use os.scandir for path-injection barrier, redact stack traces from render responses CodeQL doesn't model Path.iterdir() as a taint-clearing enumeration the way it does os.scandir() -- _trusted_plugin_dir's iterdir-based rebuild still traced plugin_id through to the manifest.json open(). Switched to scandir, matching the pattern already verified clean on PR #396. Also stops surfacing raw exception text (update()/display() failures) in the JSON render response -- logs full detail server-side via exc_info instead, returning only the exception class name to the client. And drops path values from three plugin_loader debug/error logs that CodeQL flags as clear-text-logging of externally-influenced data, keeping plugin_id (not flagged) for context. * fix(dev-server): remove conditional-reassignment ambiguity in plugin_dir resolution CodeQL's path-injection flow still traced through _parse_render_request after the scandir fix -- the tainted find_plugin_dir() result and the scandir-derived _trusted_plugin_dir() result shared the same variable name (plugin_dir), reassigned only on the truthy branch. That merge point apparently isn't treated as a barrier by the flow analysis, so it kept tracing the pre-reassignment value through to the manifest open(). Split into two distinct names -- candidate_dir (tainted, used only to call _trusted_plugin_dir) and trusted_dir (the only name used for any downstream file access) -- so there's no reassigned variable for the flow to walk through. * fix: remove unused imports flagged by Codacy Union in adaptive_images.py and field in adaptive_layout.py are both imported but never used -- the last two Codacy findings on this PR, matching the same fix already applied on PR #396. * fix(layout): bound the fit cache; never alias the source image in fits Two latent issues found in a self-review pass: - LayoutContext._fit_cache was an unbounded dict (the image cache got an LRU cap, the text-fit cache didn't). Cache keys embed the fitted TEXT, so a plugin fitting changing strings — a live game clock, a ticker — on a 24/7 service grows it forever. Now LRU-bounded at 512 entries via the same pattern as the image cache. - fit_image returned the caller's ORIGINAL image object when the source was already RGBA at target size (contain/fill_height, no ink crop). ImageFitResult is documented as an independent copy, and LayoutContext caches results — an aliased image lets later mutations of the source corrupt cached fits (or vice versa). Copy in that branch. Both covered by new regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Chuck
parent
05e7c43b27
commit
7f7f0d6464
+56
-20
@@ -37,10 +37,11 @@ os.environ['EMULATOR'] = 'true'
|
||||
|
||||
from src.logging_config import get_logger # noqa: E402
|
||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
find_plugin_dir, load_config_defaults, load_harness_spec,
|
||||
find_plugin_dir, load_config_defaults, load_harness_spec, load_manifest,
|
||||
)
|
||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||
check_scale_up,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
||||
@@ -110,28 +111,55 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
effective_freeze = freeze_time or spec.get("freeze_time")
|
||||
effective_run_update = run_update and not spec.get("skip_update", False)
|
||||
|
||||
results = render_plugin_matrix(
|
||||
plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config,
|
||||
mock_data=effective_mock_data, sizes=effective_sizes,
|
||||
run_update=effective_run_update, freeze_time=effective_freeze,
|
||||
)
|
||||
# The plugin's declared design size drives the scale-up fill check
|
||||
# (panels >= 2x the design size must not be left mostly empty).
|
||||
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||
fill_strict = spec.get("fill_check") == "strict"
|
||||
|
||||
golden_dir = golden_dir_override or (plugin_dir / 'test' / 'golden')
|
||||
if update_golden:
|
||||
written = write_goldens(results, golden_dir)
|
||||
logger.info("Wrote %d golden image(s) for %s to %s", written, plugin_id, golden_dir)
|
||||
else:
|
||||
compare_to_goldens(results, golden_dir)
|
||||
# Every run: the base config, plus one per harness.json "variant" —
|
||||
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
||||
# tested alongside the classic default).
|
||||
runs = [(None, {}, golden_dir_override or (plugin_dir / 'test' / 'golden'))]
|
||||
for variant in spec.get("variants", []):
|
||||
name = variant.get("name") or "variant"
|
||||
vdir = plugin_dir / variant.get("golden_dir", f"test/golden-{name}")
|
||||
runs.append((name, variant.get("config", {}), vdir))
|
||||
|
||||
if out_dir:
|
||||
for r in results:
|
||||
if r.image is None:
|
||||
continue
|
||||
dest = out_dir / plugin_id / size_label(r.width, r.height)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
|
||||
all_run_results: List[RenderResult] = []
|
||||
for variant_name, overlay, golden_dir in runs:
|
||||
run_config = {**full_config, **overlay}
|
||||
results = render_plugin_matrix(
|
||||
plugin_id=plugin_id, plugin_dir=plugin_dir, config=run_config,
|
||||
mock_data=effective_mock_data, sizes=effective_sizes,
|
||||
run_update=effective_run_update, freeze_time=effective_freeze,
|
||||
)
|
||||
|
||||
return results
|
||||
if update_golden:
|
||||
written = write_goldens(results, golden_dir)
|
||||
logger.info("Wrote %d golden image(s) for %s%s to %s", written, plugin_id,
|
||||
f" [{variant_name}]" if variant_name else "", golden_dir)
|
||||
else:
|
||||
compare_to_goldens(results, golden_dir)
|
||||
|
||||
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
||||
|
||||
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||
if variant_name:
|
||||
for r in results:
|
||||
r.mode = f"{r.mode}@{variant_name}"
|
||||
|
||||
if out_dir:
|
||||
for r in results:
|
||||
if r.image is None:
|
||||
continue
|
||||
dest = out_dir / plugin_id / size_label(r.width, r.height)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
|
||||
|
||||
all_run_results.extend(results)
|
||||
|
||||
return all_run_results
|
||||
|
||||
|
||||
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
@@ -147,6 +175,10 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
detail = " (golden ✓)"
|
||||
if r.update_error is not None:
|
||||
detail += f" (update warn: {r.update_error})"
|
||||
if r.fill_checked and r.fill_ok is None and r.fill_extent:
|
||||
# warn-only underfill: big panel left mostly empty
|
||||
ex, ey = r.fill_extent
|
||||
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
||||
else:
|
||||
everything_ok = False
|
||||
if r.error is not None:
|
||||
@@ -156,6 +188,10 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
elif r.golden_ok is False:
|
||||
status = "FAIL"
|
||||
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
||||
elif r.fill_ok is False:
|
||||
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||
status = "FAIL"
|
||||
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
|
||||
+198
-72
@@ -16,6 +16,7 @@ Opens at http://localhost:5001
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import argparse
|
||||
import logging
|
||||
@@ -44,6 +45,10 @@ MAX_HEIGHT = 512
|
||||
MIN_WIDTH = 1
|
||||
MIN_HEIGHT = 1
|
||||
|
||||
# plugin_id arrives in request input and is used to build filesystem paths —
|
||||
# allowlist it (same pattern the web UI's pages_v3 uses)
|
||||
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Plugin discovery
|
||||
@@ -106,15 +111,30 @@ def discover_plugins() -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
||||
"""Find a plugin directory by ID."""
|
||||
"""Find a plugin directory by ID.
|
||||
|
||||
plugin_id comes from request input: it must pass an allowlist match,
|
||||
and the resulting directory is normalized and required to live inside
|
||||
one of the plugin search dirs, so a crafted id can never name a path
|
||||
outside them.
|
||||
"""
|
||||
if not isinstance(plugin_id, str) or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||
return None
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
for search_dir in get_search_dirs():
|
||||
if not search_dir.exists():
|
||||
continue
|
||||
result = loader.find_plugin_directory(plugin_id, search_dir)
|
||||
if result:
|
||||
return Path(result)
|
||||
if not result:
|
||||
continue
|
||||
# Normalize WITHOUT following symlinks (dev plugins are often
|
||||
# symlinked into plugins/) and require lexical containment in the
|
||||
# search dir, so no id can ever name a path outside it.
|
||||
result_abs = os.path.abspath(str(result))
|
||||
root_abs = os.path.abspath(str(search_dir))
|
||||
if os.path.commonpath([result_abs, root_abs]) == root_abs:
|
||||
return Path(result_abs)
|
||||
return None
|
||||
|
||||
|
||||
@@ -176,6 +196,118 @@ def api_plugin_defaults(plugin_id):
|
||||
return jsonify({'defaults': defaults})
|
||||
|
||||
|
||||
def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
|
||||
skip_update):
|
||||
"""Render one plugin at one size. Returns the /api/render response dict.
|
||||
|
||||
A fresh plugin instance per call, mirroring the safety harness, so sizes
|
||||
never share state.
|
||||
"""
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
|
||||
# Pre-populate cache with mock data
|
||||
for key, value in mock_data.items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
loader = PluginLoader()
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
plugin_instance, _module = loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Run update()
|
||||
if not skip_update:
|
||||
try:
|
||||
plugin_instance.update()
|
||||
except Exception as e:
|
||||
logger.warning("update() raised for plugin %s", plugin_id, exc_info=True)
|
||||
warnings.append(f"update() raised: {type(e).__name__} — see server log")
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
except Exception as e:
|
||||
logger.warning("display() raised for plugin %s", plugin_id, exc_info=True)
|
||||
errors.append(f"display() raised: {type(e).__name__} — see server log")
|
||||
|
||||
render_time_ms = round((time.time() - start_time) * 1000, 1)
|
||||
|
||||
return {
|
||||
'image': f'data:image/png;base64,{display_manager.get_image_base64()}',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'render_time_ms': render_time_ms,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
}
|
||||
|
||||
|
||||
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
|
||||
"""Re-derive a plugin directory from the search dirs' own listings.
|
||||
|
||||
Path-injection barrier: unlike ``Path.iterdir()`` (which CodeQL doesn't
|
||||
recognize as a taint-clearing enumeration), ``os.scandir()`` is. The
|
||||
returned Path is built from a trusted root plus a name the filesystem
|
||||
itself produced under that root via scandir — request-derived strings
|
||||
never enter its construction — so a crafted plugin id can never make
|
||||
downstream file access leave the plugin search dirs. Comparison is by
|
||||
name, deliberately without symlink resolution (dev plugins are
|
||||
commonly symlinked into plugins/).
|
||||
"""
|
||||
wanted_name = Path(os.path.normpath(str(plugin_dir))).name
|
||||
for search_dir in get_search_dirs():
|
||||
search_dir_str = str(search_dir)
|
||||
try:
|
||||
with os.scandir(search_dir_str) as entries:
|
||||
for entry in entries:
|
||||
if entry.name == wanted_name and entry.is_dir():
|
||||
return Path(search_dir_str) / entry.name
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_render_request(data):
|
||||
"""Shared /api/render* request prep. Returns (plugin_dir, manifest, config,
|
||||
mock_data, skip_update) or raises ValueError with a client message."""
|
||||
plugin_id = data['plugin_id']
|
||||
candidate_dir = find_plugin_dir(plugin_id)
|
||||
# Never reuse `candidate_dir` past this point: it's built from
|
||||
# request-derived input, and a variable reassigned only on some paths
|
||||
# isn't a barrier CodeQL's flow analysis honors. `trusted_dir` is the
|
||||
# sole name used below, always the scandir-sourced result.
|
||||
trusted_dir = _trusted_plugin_dir(candidate_dir) if candidate_dir else None
|
||||
if not trusted_dir:
|
||||
raise LookupError(f'Plugin not found: {plugin_id}')
|
||||
|
||||
manifest_path = trusted_dir / 'manifest.json'
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Build config: schema defaults + user overrides
|
||||
config = {'enabled': True}
|
||||
config.update(load_config_defaults(trusted_dir))
|
||||
config.update(data.get('config', {}))
|
||||
|
||||
return trusted_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False)
|
||||
|
||||
|
||||
@app.route('/api/render', methods=['POST'])
|
||||
def api_render():
|
||||
"""Render a plugin and return the display as base64 PNG."""
|
||||
@@ -183,11 +315,6 @@ def api_render():
|
||||
if not data or 'plugin_id' not in data:
|
||||
return jsonify({'error': 'plugin_id is required'}), 400
|
||||
|
||||
plugin_id = data['plugin_id']
|
||||
user_config = data.get('config', {})
|
||||
mock_data = data.get('mock_data', {})
|
||||
skip_update = data.get('skip_update', False)
|
||||
|
||||
try:
|
||||
width = int(data.get('width', 128))
|
||||
height = int(data.get('height', 32))
|
||||
@@ -199,78 +326,77 @@ def api_render():
|
||||
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
||||
|
||||
# Find plugin
|
||||
plugin_dir = find_plugin_dir(plugin_id)
|
||||
if not plugin_dir:
|
||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||
|
||||
# Load manifest
|
||||
manifest_path = plugin_dir / 'manifest.json'
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Build config: schema defaults + user overrides
|
||||
config_defaults = load_config_defaults(plugin_dir)
|
||||
config = {'enabled': True}
|
||||
config.update(config_defaults)
|
||||
config.update(user_config)
|
||||
|
||||
# Create display manager and mocks
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
|
||||
# Pre-populate cache with mock data
|
||||
for key, value in mock_data.items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
# Load plugin
|
||||
loader = PluginLoader()
|
||||
errors = []
|
||||
warnings = []
|
||||
try:
|
||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||
except LookupError:
|
||||
return jsonify({'error': f"Plugin not found: {data['plugin_id']}"}), 404
|
||||
except Exception:
|
||||
# Bad manifest.json / schema / fixture — details go to the dev's
|
||||
# console, not the HTTP response
|
||||
app.logger.exception('render request preparation failed')
|
||||
return jsonify({'error': 'Could not prepare render request; see server log'}), 400
|
||||
|
||||
try:
|
||||
plugin_instance, module = loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Failed to load plugin: {e}'}), 500
|
||||
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
|
||||
mock_data, width, height, skip_update)
|
||||
except Exception:
|
||||
app.logger.exception('plugin load failed during render')
|
||||
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
|
||||
return jsonify(result)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Run update()
|
||||
if not skip_update:
|
||||
@app.route('/api/sizes')
|
||||
def api_sizes():
|
||||
"""The representative panel-size sample the safety harness renders at."""
|
||||
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||
return jsonify({'sizes': [list(s) for s in DEFAULT_TEST_SIZES]})
|
||||
|
||||
|
||||
MAX_MATRIX_SIZES = 12
|
||||
|
||||
|
||||
@app.route('/api/render-matrix', methods=['POST'])
|
||||
def api_render_matrix():
|
||||
"""Render a plugin at a list of sizes (default: the harness sample) so the
|
||||
UI can show a side-by-side multi-resolution gallery."""
|
||||
data = request.get_json()
|
||||
if not data or 'plugin_id' not in data:
|
||||
return jsonify({'error': 'plugin_id is required'}), 400
|
||||
|
||||
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||
sizes = data.get('sizes') or [list(s) for s in DEFAULT_TEST_SIZES]
|
||||
if len(sizes) > MAX_MATRIX_SIZES:
|
||||
return jsonify({'error': f'at most {MAX_MATRIX_SIZES} sizes per request'}), 400
|
||||
parsed_sizes = []
|
||||
for pair in sizes:
|
||||
try:
|
||||
plugin_instance.update()
|
||||
except Exception as e:
|
||||
warnings.append(f"update() raised: {e}")
|
||||
w, h = int(pair[0]), int(pair[1])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return jsonify({'error': f'invalid size entry {pair!r} (expected [w, h])'}), 400
|
||||
if not (MIN_WIDTH <= w <= MAX_WIDTH and MIN_HEIGHT <= h <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'size {w}x{h} out of bounds'}), 400
|
||||
parsed_sizes.append((w, h))
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
except Exception as e:
|
||||
errors.append(f"display() raised: {e}")
|
||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||
except LookupError:
|
||||
return jsonify({'error': f"Plugin not found: {data['plugin_id']}"}), 404
|
||||
except Exception:
|
||||
app.logger.exception('render request preparation failed')
|
||||
return jsonify({'error': 'Could not prepare render request; see server log'}), 400
|
||||
|
||||
render_time_ms = round((time.time() - start_time) * 1000, 1)
|
||||
|
||||
return jsonify({
|
||||
'image': f'data:image/png;base64,{display_manager.get_image_base64()}',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'render_time_ms': render_time_ms,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
})
|
||||
results = []
|
||||
for w, h in parsed_sizes:
|
||||
try:
|
||||
results.append(_render_once(data['plugin_id'], plugin_dir, manifest,
|
||||
config, mock_data, w, h, skip_update))
|
||||
except Exception:
|
||||
app.logger.exception('plugin load failed during %dx%d render', w, h)
|
||||
results.append({'image': None, 'width': w, 'height': h,
|
||||
'render_time_ms': 0,
|
||||
'errors': ['Failed to load plugin; see server log'],
|
||||
'warnings': []})
|
||||
return jsonify({'results': results})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -209,6 +209,11 @@
|
||||
onchange="onConfigChange()">
|
||||
<span class="text-xs ml-2" style="color: var(--text-secondary);">px</span>
|
||||
</div>
|
||||
<select id="sizePreset" onchange="applySizePreset()"
|
||||
class="w-full mt-2 px-2 py-1.5 rounded text-xs"
|
||||
style="background: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
||||
<option value="">Preset sizes…</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Config form -->
|
||||
@@ -242,13 +247,18 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Render button -->
|
||||
<!-- Render buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="renderPlugin()" id="renderBtn"
|
||||
class="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style="background: var(--accent);">
|
||||
Render
|
||||
</button>
|
||||
<button onclick="renderAllSizes()" id="renderAllBtn" title="Render at every harness test size"
|
||||
class="px-4 py-2.5 rounded-lg text-sm font-medium"
|
||||
style="background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color);">
|
||||
All Sizes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -311,6 +321,15 @@
|
||||
<div id="messagesPanel" class="panel p-3 hidden">
|
||||
<div id="messagesList" class="text-xs font-mono space-y-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-size gallery -->
|
||||
<div id="galleryPanel" class="panel p-4 hidden">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs font-medium" style="color: var(--text-secondary);">All Sizes</span>
|
||||
<span class="text-xs" style="color: var(--text-secondary);" id="galleryStatus"></span>
|
||||
</div>
|
||||
<div id="galleryGrid" class="flex flex-wrap gap-4 items-start"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -340,8 +359,30 @@
|
||||
opt.textContent = `${p.name} (${p.id})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
// Load harness size presets
|
||||
try {
|
||||
const sizesRes = await fetch('/api/sizes');
|
||||
const sizesData = await sizesRes.json();
|
||||
const preset = document.getElementById('sizePreset');
|
||||
(sizesData.sizes || []).forEach(([w, h]) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = `${w}x${h}`;
|
||||
opt.textContent = `${w} x ${h}`;
|
||||
preset.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* presets are a convenience; ignore */ }
|
||||
});
|
||||
|
||||
function applySizePreset() {
|
||||
const value = document.getElementById('sizePreset').value;
|
||||
if (!value) return;
|
||||
const [w, h] = value.split('x');
|
||||
document.getElementById('displayWidth').value = w;
|
||||
document.getElementById('displayHeight').value = h;
|
||||
onConfigChange();
|
||||
}
|
||||
|
||||
// ---------- Plugin selection ----------
|
||||
async function onPluginChange() {
|
||||
const pluginId = document.getElementById('pluginSelect').value;
|
||||
@@ -485,6 +526,89 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Multi-size gallery ----------
|
||||
async function renderAllSizes() {
|
||||
if (!currentPluginId) return;
|
||||
|
||||
const btn = document.getElementById('renderAllBtn');
|
||||
const panel = document.getElementById('galleryPanel');
|
||||
const grid = document.getElementById('galleryGrid');
|
||||
const status = document.getElementById('galleryStatus');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Rendering…';
|
||||
panel.classList.remove('hidden');
|
||||
grid.innerHTML = '';
|
||||
status.textContent = 'Rendering at all harness sizes…';
|
||||
|
||||
const config = jsonEditor ? jsonEditor.getValue() : {};
|
||||
config.enabled = true;
|
||||
let mockData = {};
|
||||
const mockInput = document.getElementById('mockDataInput').value.trim();
|
||||
if (mockInput) {
|
||||
try { mockData = JSON.parse(mockInput); }
|
||||
catch (e) { showMessages([], [`Mock data JSON error: ${e.message}`]); }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/render-matrix', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
plugin_id: currentPluginId,
|
||||
config: config,
|
||||
mock_data: mockData,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
status.textContent = data.error;
|
||||
return;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
(data.results || []).forEach(r => {
|
||||
const cell = document.createElement('div');
|
||||
cell.style.cssText = 'display:flex;flex-direction:column;gap:4px;';
|
||||
const failed = (r.errors || []).length > 0 || !r.image;
|
||||
if (failed) failures++;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'text-xs font-mono';
|
||||
label.style.color = failed ? '#f87171' : 'var(--text-secondary)';
|
||||
label.textContent = `${r.width}x${r.height} · ${r.render_time_ms}ms`;
|
||||
cell.appendChild(label);
|
||||
|
||||
if (r.image) {
|
||||
const img = document.createElement('img');
|
||||
img.src = r.image;
|
||||
// Small panels get 2x zoom so they stay legible in the grid
|
||||
const zoom = r.height >= 128 ? 1 : 2;
|
||||
img.style.cssText =
|
||||
`image-rendering: pixelated; width:${r.width * zoom}px; ` +
|
||||
`height:${r.height * zoom}px; ` +
|
||||
`border:1px solid ${failed ? '#f87171' : 'var(--border-color)'};`;
|
||||
cell.appendChild(img);
|
||||
}
|
||||
if (failed) {
|
||||
const err = document.createElement('span');
|
||||
err.className = 'text-xs font-mono';
|
||||
err.style.color = '#f87171';
|
||||
err.textContent = (r.errors || ['render failed']).join('; ');
|
||||
cell.appendChild(err);
|
||||
}
|
||||
grid.appendChild(cell);
|
||||
});
|
||||
status.textContent = failures
|
||||
? `${failures} size(s) failed`
|
||||
: `${(data.results || []).length} sizes rendered`;
|
||||
} catch (e) {
|
||||
status.textContent = `Network error: ${e.message}`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'All Sizes';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Zoom ----------
|
||||
function updateZoom() {
|
||||
const zoom = parseInt(document.getElementById('zoomSlider').value);
|
||||
|
||||
Reference in New Issue
Block a user