diff --git a/.gitignore b/.gitignore index 090657d76..eac269b26 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,12 @@ htmlcov/ # See docs/MULTI_ROOT_WORKSPACE_SETUP.md for details plugins/* !plugins/.gitkeep + +# Pixlet bundled binaries +bin/pixlet/ + +# Starlark apps data +starlark-apps/* +!starlark-apps/.gitkeep +!starlark-apps/manifest.json +!starlark-apps/README.md diff --git a/plugin-repos/starlark-apps/__init__.py b/plugin-repos/starlark-apps/__init__.py new file mode 100644 index 000000000..1d5dabca4 --- /dev/null +++ b/plugin-repos/starlark-apps/__init__.py @@ -0,0 +1,7 @@ +""" +Starlark Apps Plugin Package + +Seamlessly import and manage Starlark (.star) widgets from the Tronbyte/Tidbyt community. +""" + +__version__ = "1.0.0" diff --git a/plugin-repos/starlark-apps/config_schema.json b/plugin-repos/starlark-apps/config_schema.json new file mode 100644 index 000000000..e493204f2 --- /dev/null +++ b/plugin-repos/starlark-apps/config_schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Starlark Apps Plugin Configuration", + "description": "Configuration for managing Starlark (.star) apps", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the Starlark apps system", + "default": true + }, + "pixlet_path": { + "type": "string", + "description": "Path to Pixlet binary (auto-detected if empty)", + "default": "" + }, + "render_timeout": { + "type": "number", + "description": "Maximum time in seconds for rendering a .star app", + "default": 30, + "minimum": 5, + "maximum": 120 + }, + "cache_rendered_output": { + "type": "boolean", + "description": "Cache rendered WebP output to reduce CPU usage", + "default": true + }, + "cache_ttl": { + "type": "number", + "description": "Cache time-to-live in seconds", + "default": 300, + "minimum": 60, + "maximum": 3600 + }, + "default_frame_delay": { + "type": "number", + "description": "Default delay between frames in milliseconds (if not specified by app)", + "default": 50, + "minimum": 16, + "maximum": 1000 + }, + "scale_output": { + "type": "boolean", + "description": "Scale app output to match display dimensions", + "default": true + }, + "scale_method": { + "type": "string", + "enum": ["nearest", "bilinear", "bicubic", "lanczos"], + "description": "Scaling algorithm (nearest=pixel-perfect, lanczos=smoothest)", + "default": "nearest" + }, + "magnify": { + "type": "integer", + "description": "Pixlet magnification factor (0=auto, 1=64x32, 2=128x64, 3=192x96, etc.)", + "default": 0, + "minimum": 0, + "maximum": 8 + }, + "center_small_output": { + "type": "boolean", + "description": "Center small apps on large displays instead of stretching", + "default": false + }, + "background_render": { + "type": "boolean", + "description": "Render apps in background to avoid display delays", + "default": true + }, + "auto_refresh_apps": { + "type": "boolean", + "description": "Automatically refresh apps at their specified intervals", + "default": true + }, + "transition": { + "type": "object", + "description": "Transition settings for app display", + "properties": { + "type": { + "type": "string", + "enum": ["redraw", "fade", "slide", "wipe"], + "default": "fade" + }, + "speed": { + "type": "integer", + "description": "Transition speed (1-10)", + "default": 3, + "minimum": 1, + "maximum": 10 + }, + "enabled": { + "type": "boolean", + "default": true + } + } + } + }, + "additionalProperties": false +} diff --git a/plugin-repos/starlark-apps/frame_extractor.py b/plugin-repos/starlark-apps/frame_extractor.py new file mode 100644 index 000000000..bb52d9e18 --- /dev/null +++ b/plugin-repos/starlark-apps/frame_extractor.py @@ -0,0 +1,285 @@ +""" +Frame Extractor Module for Starlark Apps + +Extracts individual frames from WebP animations produced by Pixlet. +Handles both static images and animated WebP files. +""" + +import logging +from typing import List, Tuple, Optional +from PIL import Image + +logger = logging.getLogger(__name__) + + +class FrameExtractor: + """ + Extracts frames from WebP animations. + + Handles: + - Static WebP images (single frame) + - Animated WebP files (multiple frames with delays) + - Frame timing and duration extraction + """ + + def __init__(self, default_frame_delay: int = 50): + """ + Initialize frame extractor. + + Args: + default_frame_delay: Default delay in milliseconds if not specified + """ + self.default_frame_delay = default_frame_delay + + def load_webp(self, webp_path: str) -> Tuple[bool, Optional[List[Tuple[Image.Image, int]]], Optional[str]]: + """ + Load WebP file and extract all frames with their delays. + + Args: + webp_path: Path to WebP file + + Returns: + Tuple of: + - success: bool + - frames: List of (PIL.Image, delay_ms) tuples, or None on failure + - error: Error message, or None on success + """ + try: + with Image.open(webp_path) as img: + # Check if animated + is_animated = getattr(img, "is_animated", False) + + if not is_animated: + # Static image - single frame + # Convert to RGB (LED matrix needs RGB) to match animated branch format + logger.debug(f"Loaded static WebP: {webp_path}") + rgb_img = img.convert("RGB") + return True, [(rgb_img.copy(), self.default_frame_delay)], None + + # Animated WebP - extract all frames + frames = [] + frame_count = getattr(img, "n_frames", 1) + + logger.debug(f"Extracting {frame_count} frames from animated WebP: {webp_path}") + + for frame_index in range(frame_count): + try: + img.seek(frame_index) + + # Get frame duration (in milliseconds) + # WebP stores duration in milliseconds + duration = img.info.get("duration", self.default_frame_delay) + + # Ensure minimum frame delay (prevent too-fast animations) + if duration < 16: # Less than ~60fps + duration = 16 + + # Convert frame to RGB (LED matrix needs RGB) + frame = img.convert("RGB") + frames.append((frame.copy(), duration)) + + except EOFError: + logger.warning(f"Reached end of frames at index {frame_index}") + break + except Exception as e: + logger.warning(f"Error extracting frame {frame_index}: {e}") + continue + + if not frames: + error = "No frames extracted from WebP" + logger.error(error) + return False, None, error + + logger.debug(f"Successfully extracted {len(frames)} frames") + return True, frames, None + + except FileNotFoundError: + error = f"WebP file not found: {webp_path}" + logger.error(error) + return False, None, error + except Exception as e: + error = f"Error loading WebP: {e}" + logger.error(error) + return False, None, error + + def scale_frames( + self, + frames: List[Tuple[Image.Image, int]], + target_width: int, + target_height: int, + method: Image.Resampling = Image.Resampling.NEAREST + ) -> List[Tuple[Image.Image, int]]: + """ + Scale all frames to target dimensions. + + Args: + frames: List of (image, delay) tuples + target_width: Target width in pixels + target_height: Target height in pixels + method: Resampling method (default: NEAREST for pixel-perfect scaling) + + Returns: + List of scaled (image, delay) tuples + """ + scaled_frames = [] + + for frame, delay in frames: + try: + # Only scale if dimensions don't match + if frame.width != target_width or frame.height != target_height: + scaled_frame = frame.resize( + (target_width, target_height), + resample=method + ) + scaled_frames.append((scaled_frame, delay)) + else: + scaled_frames.append((frame, delay)) + except Exception as e: + logger.warning(f"Error scaling frame: {e}") + # Keep original frame on error + scaled_frames.append((frame, delay)) + + logger.debug(f"Scaled {len(scaled_frames)} frames to {target_width}x{target_height}") + return scaled_frames + + def center_frames( + self, + frames: List[Tuple[Image.Image, int]], + target_width: int, + target_height: int, + background_color: tuple = (0, 0, 0) + ) -> List[Tuple[Image.Image, int]]: + """ + Center frames on a larger canvas instead of scaling. + Useful for displaying small widgets on large displays without distortion. + + Args: + frames: List of (image, delay) tuples + target_width: Target canvas width + target_height: Target canvas height + background_color: RGB tuple for background (default: black) + + Returns: + List of centered (image, delay) tuples + """ + centered_frames = [] + + for frame, delay in frames: + try: + # If frame is already the right size, no centering needed + if frame.width == target_width and frame.height == target_height: + centered_frames.append((frame, delay)) + continue + + # Create black canvas at target size + canvas = Image.new('RGB', (target_width, target_height), background_color) + + # Calculate position to center the frame + x_offset = (target_width - frame.width) // 2 + y_offset = (target_height - frame.height) // 2 + + # Paste frame onto canvas + canvas.paste(frame, (x_offset, y_offset)) + centered_frames.append((canvas, delay)) + + except Exception as e: + logger.warning(f"Error centering frame: {e}") + # Keep original frame on error + centered_frames.append((frame, delay)) + + logger.debug(f"Centered {len(centered_frames)} frames on {target_width}x{target_height} canvas") + return centered_frames + + def get_total_duration(self, frames: List[Tuple[Image.Image, int]]) -> int: + """ + Calculate total animation duration in milliseconds. + + Args: + frames: List of (image, delay) tuples + + Returns: + Total duration in milliseconds + """ + return sum(delay for _, delay in frames) + + def optimize_frames( + self, + frames: List[Tuple[Image.Image, int]], + max_frames: Optional[int] = None, + target_duration: Optional[int] = None + ) -> List[Tuple[Image.Image, int]]: + """ + Optimize frame list by reducing frame count or adjusting timing. + + Args: + frames: List of (image, delay) tuples + max_frames: Maximum number of frames to keep + target_duration: Target total duration in milliseconds + + Returns: + Optimized list of (image, delay) tuples + """ + if not frames: + return frames + + optimized = frames.copy() + + # Limit frame count if specified + if max_frames is not None and max_frames > 0 and len(optimized) > max_frames: + # Sample frames evenly + step = len(optimized) / max_frames + indices = [int(i * step) for i in range(max_frames)] + optimized = [optimized[i] for i in indices] + logger.debug(f"Reduced frames from {len(frames)} to {len(optimized)}") + + # Adjust timing to match target duration + if target_duration: + current_duration = self.get_total_duration(optimized) + if current_duration > 0: + scale_factor = target_duration / current_duration + optimized = [ + (frame, max(16, int(delay * scale_factor))) + for frame, delay in optimized + ] + logger.debug(f"Adjusted timing: {current_duration}ms -> {target_duration}ms") + + return optimized + + def frames_to_gif_data(self, frames: List[Tuple[Image.Image, int]]) -> Optional[bytes]: + """ + Convert frames to GIF byte data for caching or transmission. + + Args: + frames: List of (image, delay) tuples + + Returns: + GIF bytes, or None on error + """ + if not frames: + return None + + try: + from io import BytesIO + + output = BytesIO() + + # Prepare frames for PIL + images = [frame for frame, _ in frames] + durations = [delay for _, delay in frames] + + # Save as GIF + images[0].save( + output, + format="GIF", + save_all=True, + append_images=images[1:], + duration=durations, + loop=0, # Infinite loop + optimize=False # Skip optimization for speed + ) + + return output.getvalue() + + except Exception as e: + logger.error(f"Error converting frames to GIF: {e}") + return None diff --git a/plugin-repos/starlark-apps/manager.py b/plugin-repos/starlark-apps/manager.py new file mode 100644 index 000000000..c0ec41b04 --- /dev/null +++ b/plugin-repos/starlark-apps/manager.py @@ -0,0 +1,823 @@ +""" +Starlark Apps Plugin for LEDMatrix + +Manages and displays Starlark (.star) apps from Tronbyte/Tidbyt community. +Provides seamless widget import without modification. + +API Version: 1.0.0 +""" + +import json +import logging +import os +import re +import time +from pathlib import Path +from typing import Dict, Any, Optional, List, Tuple +from PIL import Image + +from src.plugin_system.base_plugin import BasePlugin +from .pixlet_renderer import PixletRenderer +from .frame_extractor import FrameExtractor + +logger = logging.getLogger(__name__) + + +class StarlarkApp: + """Represents a single installed Starlark app.""" + + def __init__(self, app_id: str, app_dir: Path, manifest: Dict[str, Any]): + """ + Initialize a Starlark app instance. + + Args: + app_id: Unique identifier for this app + app_dir: Directory containing the app files + manifest: App metadata from manifest + """ + self.app_id = app_id + self.app_dir = app_dir + self.manifest = manifest + self.star_file = app_dir / manifest.get("star_file", f"{app_id}.star") + self.config_file = app_dir / "config.json" + self.schema_file = app_dir / "schema.json" + self.cache_file = app_dir / "cached_render.webp" + + # Load app configuration + self.config = self._load_config() + self.schema = self._load_schema() + + # Runtime state + self.frames: Optional[List[Tuple[Image.Image, int]]] = None + self.current_frame_index = 0 + self.last_frame_time = 0 + self.last_render_time = 0 + + def _load_config(self) -> Dict[str, Any]: + """Load app configuration from config.json.""" + if self.config_file.exists(): + try: + with open(self.config_file, 'r') as f: + return json.load(f) + except Exception as e: + logger.warning(f"Could not load config for {self.app_id}: {e}") + return {} + + def _load_schema(self) -> Optional[Dict[str, Any]]: + """Load app schema from schema.json.""" + if self.schema_file.exists(): + try: + with open(self.schema_file, 'r') as f: + return json.load(f) + except Exception as e: + logger.warning(f"Could not load schema for {self.app_id}: {e}") + return None + + def save_config(self) -> bool: + """Save current configuration to file.""" + try: + with open(self.config_file, 'w') as f: + json.dump(self.config, f, indent=2) + return True + except Exception as e: + logger.exception(f"Could not save config for {self.app_id}: {e}") + return False + + def is_enabled(self) -> bool: + """Check if app is enabled.""" + return self.manifest.get("enabled", True) + + def get_render_interval(self) -> int: + """Get render interval in seconds.""" + default = 300 + try: + value = self.manifest.get("render_interval", default) + interval = int(value) + except (ValueError, TypeError): + interval = default + + # Clamp to safe range: min 5, max 3600 + return max(5, min(interval, 3600)) + + def get_display_duration(self) -> int: + """Get display duration in seconds.""" + default = 15 + try: + value = self.manifest.get("display_duration", default) + duration = int(value) + except (ValueError, TypeError): + duration = default + + # Clamp to safe range: min 1, max 600 + return max(1, min(duration, 600)) + + def should_render(self, current_time: float) -> bool: + """Check if app should be re-rendered based on interval.""" + interval = self.get_render_interval() + return (current_time - self.last_render_time) >= interval + + +class StarlarkAppsPlugin(BasePlugin): + """ + Starlark Apps Manager plugin. + + Manages Starlark (.star) apps and renders them using Pixlet. + Each installed app becomes a dynamic display mode. + """ + + def __init__(self, plugin_id: str, config: Dict[str, Any], + display_manager, cache_manager, plugin_manager): + """Initialize the Starlark Apps plugin.""" + super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) + + # Initialize components + self.pixlet = PixletRenderer( + pixlet_path=config.get("pixlet_path"), + timeout=config.get("render_timeout", 30) + ) + self.extractor = FrameExtractor( + default_frame_delay=config.get("default_frame_delay", 50) + ) + + # App storage + self.apps_dir = self._get_apps_directory() + self.manifest_file = self.apps_dir / "manifest.json" + self.apps: Dict[str, StarlarkApp] = {} + + # Display state + self.current_app: Optional[StarlarkApp] = None + self.last_update_check = 0 + + # Check Pixlet availability + if not self.pixlet.is_available(): + self.logger.error("Pixlet not available - Starlark apps will not work") + self.logger.error("Install Pixlet or place bundled binary in bin/pixlet/") + else: + version = self.pixlet.get_version() + self.logger.info(f"Pixlet available: {version}") + + # Calculate optimal magnification based on display size + self.calculated_magnify = self._calculate_optimal_magnify() + if self.calculated_magnify > 1: + self.logger.info(f"Display size: {self.display_manager.matrix.width}x{self.display_manager.matrix.height}, " + f"recommended magnify: {self.calculated_magnify}") + + # Load installed apps + self._load_installed_apps() + + self.logger.info(f"Starlark Apps plugin initialized with {len(self.apps)} apps") + + @property + def modes(self) -> List[str]: + """ + Return list of display modes (one per installed Starlark app). + + This allows each installed app to appear as a separate display mode + in the schedule/rotation system. + + Returns: + List of app IDs that can be used as display modes + """ + # Return list of enabled app IDs as display modes + return [app.app_id for app in self.apps.values() if app.is_enabled()] + + def validate_config(self) -> bool: + """ + Validate plugin configuration. + + Ensures required configuration values are valid for Starlark apps. + + Returns: + True if configuration is valid, False otherwise + """ + # Call parent validation first + if not super().validate_config(): + return False + + # Validate magnify range (0-8) + if "magnify" in self.config: + magnify = self.config["magnify"] + if not isinstance(magnify, int) or magnify < 0 or magnify > 8: + self.logger.error("magnify must be an integer between 0 and 8") + return False + + # Validate render_timeout + if "render_timeout" in self.config: + timeout = self.config["render_timeout"] + if not isinstance(timeout, (int, float)) or timeout < 5 or timeout > 120: + self.logger.error("render_timeout must be a number between 5 and 120") + return False + + # Validate cache_ttl + if "cache_ttl" in self.config: + ttl = self.config["cache_ttl"] + if not isinstance(ttl, (int, float)) or ttl < 60 or ttl > 3600: + self.logger.error("cache_ttl must be a number between 60 and 3600") + return False + + # Validate scale_method + if "scale_method" in self.config: + method = self.config["scale_method"] + valid_methods = ["nearest", "bilinear", "bicubic", "lanczos"] + if method not in valid_methods: + self.logger.error(f"scale_method must be one of: {', '.join(valid_methods)}") + return False + + # Validate default_frame_delay + if "default_frame_delay" in self.config: + delay = self.config["default_frame_delay"] + if not isinstance(delay, (int, float)) or delay < 16 or delay > 1000: + self.logger.error("default_frame_delay must be a number between 16 and 1000") + return False + + return True + + def _calculate_optimal_magnify(self) -> int: + """ + Calculate optimal magnification factor based on display dimensions. + + Tronbyte apps are designed for 64x32 displays. + This calculates what magnification would best fit the current display. + + Returns: + Recommended magnify value (1-8) + """ + try: + display_width = self.display_manager.matrix.width + display_height = self.display_manager.matrix.height + + # Tronbyte native resolution + NATIVE_WIDTH = 64 + NATIVE_HEIGHT = 32 + + # Calculate scale factors for width and height + width_scale = display_width / NATIVE_WIDTH + height_scale = display_height / NATIVE_HEIGHT + + # Use the smaller scale to ensure content fits + # (prevents overflow on one dimension) + scale_factor = min(width_scale, height_scale) + + # Round down to get integer magnify value + magnify = int(scale_factor) + + # Clamp to reasonable range (1-8) + magnify = max(1, min(8, magnify)) + + self.logger.debug(f"Display: {display_width}x{display_height}, " + f"Native: {NATIVE_WIDTH}x{NATIVE_HEIGHT}, " + f"Calculated magnify: {magnify}") + + return magnify + + except Exception as e: + self.logger.warning(f"Could not calculate magnify: {e}") + return 1 + + def get_magnify_recommendation(self) -> Dict[str, Any]: + """ + Get detailed magnification recommendation for current display. + + Returns: + Dictionary with recommendation details + """ + try: + display_width = self.display_manager.matrix.width + display_height = self.display_manager.matrix.height + + NATIVE_WIDTH = 64 + NATIVE_HEIGHT = 32 + + width_scale = display_width / NATIVE_WIDTH + height_scale = display_height / NATIVE_HEIGHT + + # Calculate for different magnify values + recommendations = [] + for magnify in range(1, 9): + render_width = NATIVE_WIDTH * magnify + render_height = NATIVE_HEIGHT * magnify + + # Check if this magnify fits perfectly + perfect_fit = (render_width == display_width and render_height == display_height) + + # Check if scaling is needed + needs_scaling = (render_width != display_width or render_height != display_height) + + # Calculate quality score (1-100) + if perfect_fit: + quality_score = 100 + elif not needs_scaling: + quality_score = 95 + else: + # Score based on how close to display size + width_ratio = min(render_width, display_width) / max(render_width, display_width) + height_ratio = min(render_height, display_height) / max(render_height, display_height) + quality_score = int((width_ratio + height_ratio) / 2 * 100) + + recommendations.append({ + 'magnify': magnify, + 'render_size': f"{render_width}x{render_height}", + 'perfect_fit': perfect_fit, + 'needs_scaling': needs_scaling, + 'quality_score': quality_score, + 'recommended': magnify == self.calculated_magnify + }) + + return { + 'display_size': f"{display_width}x{display_height}", + 'native_size': f"{NATIVE_WIDTH}x{NATIVE_HEIGHT}", + 'calculated_magnify': self.calculated_magnify, + 'width_scale': round(width_scale, 2), + 'height_scale': round(height_scale, 2), + 'recommendations': recommendations + } + + except Exception as e: + self.logger.exception(f"Error getting magnify recommendation: {e}") + return { + 'display_size': 'unknown', + 'calculated_magnify': 1, + 'recommendations': [] + } + + def _get_effective_magnify(self) -> int: + """ + Get the effective magnify value to use for rendering. + + Priority: + 1. User-configured magnify (if valid and in range 1-8) + 2. Auto-calculated magnify + + Returns: + Magnify value to use + """ + config_magnify = self.config.get("magnify") + + # Validate and clamp config_magnify + if config_magnify is not None: + try: + # Convert to int if possible + config_magnify = int(config_magnify) + # Clamp to safe range (1-8) + if 1 <= config_magnify <= 8: + return config_magnify + except (ValueError, TypeError): + # Non-numeric value, fall through to calculated + pass + + # Fall back to auto-calculated value + return self.calculated_magnify + + def _get_apps_directory(self) -> Path: + """Get the directory for storing Starlark apps.""" + try: + # Try to find project root + current_dir = Path(__file__).resolve().parent + project_root = current_dir.parent.parent + apps_dir = project_root / "starlark-apps" + except Exception: + # Fallback to current working directory + apps_dir = Path.cwd() / "starlark-apps" + + # Create directory if it doesn't exist + apps_dir.mkdir(parents=True, exist_ok=True) + return apps_dir + + def _sanitize_app_id(self, app_id: str) -> str: + """ + Sanitize app_id into a safe slug for use in file paths. + + Args: + app_id: Original app identifier + + Returns: + Sanitized slug containing only [a-z0-9_.-] characters + """ + if not app_id: + raise ValueError("app_id cannot be empty") + + # Replace invalid characters with underscore + # Allow only: lowercase letters, digits, underscore, period, hyphen + safe_slug = re.sub(r'[^a-z0-9_.-]', '_', app_id.lower()) + + # Remove leading/trailing dots, underscores, or hyphens + safe_slug = safe_slug.strip('._-') + + # Ensure it's not empty after sanitization + if not safe_slug: + raise ValueError(f"app_id '{app_id}' becomes empty after sanitization") + + return safe_slug + + def _verify_path_safety(self, path: Path, base_dir: Path) -> None: + """ + Verify that a path is within the base directory to prevent path traversal. + + Args: + path: Path to verify + base_dir: Base directory that path must be within + + Raises: + ValueError: If path escapes the base directory + """ + try: + resolved_path = path.resolve() + resolved_base = base_dir.resolve() + + # Check if path is relative to base directory + if not resolved_path.is_relative_to(resolved_base): + raise ValueError( + f"Path traversal detected: {resolved_path} is not within {resolved_base}" + ) + except (ValueError, AttributeError) as e: + # AttributeError for Python < 3.9 where is_relative_to doesn't exist + # Fallback: check if resolved path starts with resolved base + resolved_path = path.resolve() + resolved_base = base_dir.resolve() + + try: + resolved_path.relative_to(resolved_base) + except ValueError: + raise ValueError( + f"Path traversal detected: {resolved_path} is not within {resolved_base}" + ) from e + + def _load_installed_apps(self) -> None: + """Load all installed apps from manifest.""" + if not self.manifest_file.exists(): + # Create initial manifest + self._save_manifest({"apps": {}}) + return + + try: + with open(self.manifest_file, 'r') as f: + manifest = json.load(f) + + apps_data = manifest.get("apps", {}) + for app_id, app_manifest in apps_data.items(): + try: + # Sanitize app_id to prevent path traversal + safe_app_id = self._sanitize_app_id(app_id) + app_dir = (self.apps_dir / safe_app_id).resolve() + + # Verify path safety + self._verify_path_safety(app_dir, self.apps_dir) + except ValueError as e: + self.logger.warning(f"Invalid app_id '{app_id}': {e}") + continue + + if not app_dir.exists(): + self.logger.warning(f"App directory missing: {app_id}") + continue + + try: + # Use safe_app_id for internal storage to match directory structure + app = StarlarkApp(safe_app_id, app_dir, app_manifest) + self.apps[safe_app_id] = app + self.logger.debug(f"Loaded app: {app_id} (sanitized: {safe_app_id})") + except Exception as e: + self.logger.exception(f"Error loading app {app_id}: {e}") + + self.logger.info(f"Loaded {len(self.apps)} Starlark apps") + + except Exception as e: + self.logger.exception(f"Error loading apps manifest: {e}") + + def _save_manifest(self, manifest: Dict[str, Any]) -> bool: + """Save apps manifest to file.""" + try: + with open(self.manifest_file, 'w') as f: + json.dump(manifest, f, indent=2) + return True + except Exception as e: + self.logger.error(f"Error saving manifest: {e}") + return False + + def update(self) -> None: + """Update method - check if apps need re-rendering.""" + current_time = time.time() + + # Check apps that need re-rendering based on their intervals + if self.config.get("auto_refresh_apps", True): + for app in self.apps.values(): + if app.is_enabled() and app.should_render(current_time): + self._render_app(app, force=False) + + def display(self, force_clear: bool = False) -> None: + """ + Display current Starlark app. + + This method is called during the display rotation. + Displays frames from the currently active app. + """ + try: + if force_clear: + self.display_manager.clear() + + # If no current app, try to select one + if not self.current_app: + self._select_next_app() + + if not self.current_app: + # No apps available + self.logger.debug("No Starlark apps to display") + return + + # Render app if needed + if not self.current_app.frames: + success = self._render_app(self.current_app, force=True) + if not success: + self.logger.error(f"Failed to render app: {self.current_app.app_id}") + return + + # Display current frame + self._display_frame() + + except Exception as e: + self.logger.error(f"Error displaying Starlark app: {e}") + + def _select_next_app(self) -> None: + """Select the next enabled app for display.""" + enabled_apps = [app for app in self.apps.values() if app.is_enabled()] + + if not enabled_apps: + self.current_app = None + return + + # Simple rotation - could be enhanced with priorities + if self.current_app and self.current_app in enabled_apps: + current_idx = enabled_apps.index(self.current_app) + next_idx = (current_idx + 1) % len(enabled_apps) + self.current_app = enabled_apps[next_idx] + else: + self.current_app = enabled_apps[0] + + self.logger.debug(f"Selected app for display: {self.current_app.app_id}") + + def _render_app(self, app: StarlarkApp, force: bool = False) -> bool: + """ + Render a Starlark app using Pixlet. + + Args: + app: App to render + force: Force render even if cached + + Returns: + True if successful + """ + try: + current_time = time.time() + + # Check cache + use_cache = self.config.get("cache_rendered_output", True) + cache_ttl = self.config.get("cache_ttl", 300) + + if (not force and use_cache and app.cache_file.exists() and + (current_time - app.last_render_time) < cache_ttl): + # Use cached render + self.logger.debug(f"Using cached render for: {app.app_id}") + return self._load_frames_from_cache(app) + + # Render with Pixlet + self.logger.info(f"Rendering app: {app.app_id}") + + # Get effective magnification factor (config or auto-calculated) + magnify = self._get_effective_magnify() + self.logger.debug(f"Using magnify={magnify} for {app.app_id}") + + success, error = self.pixlet.render( + star_file=str(app.star_file), + output_path=str(app.cache_file), + config=app.config, + magnify=magnify + ) + + if not success: + self.logger.error(f"Pixlet render failed: {error}") + return False + + # Extract frames + success = self._load_frames_from_cache(app) + if success: + app.last_render_time = current_time + + return success + + except Exception as e: + self.logger.error(f"Error rendering app {app.app_id}: {e}") + return False + + def _load_frames_from_cache(self, app: StarlarkApp) -> bool: + """Load frames from cached WebP file.""" + try: + success, frames, error = self.extractor.load_webp(str(app.cache_file)) + + if not success: + self.logger.error(f"Frame extraction failed: {error}") + return False + + # Scale frames if needed + if self.config.get("scale_output", True): + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + + # Get scaling method from config + scale_method_str = self.config.get("scale_method", "nearest") + scale_method_map = { + "nearest": Image.Resampling.NEAREST, + "bilinear": Image.Resampling.BILINEAR, + "bicubic": Image.Resampling.BICUBIC, + "lanczos": Image.Resampling.LANCZOS + } + scale_method = scale_method_map.get(scale_method_str, Image.Resampling.NEAREST) + + # Check if we should center instead of scale + if self.config.get("center_small_output", False): + frames = self.extractor.center_frames(frames, width, height) + else: + frames = self.extractor.scale_frames(frames, width, height, scale_method) + + # Optimize frames to limit memory usage (max_frames=None means no limit) + max_frames = self.config.get("max_frames") + if max_frames is not None: + frames = self.extractor.optimize_frames(frames, max_frames=max_frames) + + app.frames = frames + app.current_frame_index = 0 + app.last_frame_time = time.time() + + self.logger.debug(f"Loaded {len(frames)} frames for {app.app_id}") + return True + + except Exception as e: + self.logger.error(f"Error loading frames for {app.app_id}: {e}") + return False + + def _display_frame(self) -> None: + """Display the current frame of the current app.""" + if not self.current_app or not self.current_app.frames: + return + + try: + current_time = time.time() + frame, delay_ms = self.current_app.frames[self.current_app.current_frame_index] + + # Set frame on display manager + self.display_manager.image = frame + self.display_manager.update_display() + + # Check if it's time to advance to next frame + delay_seconds = delay_ms / 1000.0 + if (current_time - self.current_app.last_frame_time) >= delay_seconds: + self.current_app.current_frame_index = ( + (self.current_app.current_frame_index + 1) % len(self.current_app.frames) + ) + self.current_app.last_frame_time = current_time + + except Exception as e: + self.logger.error(f"Error displaying frame: {e}") + + def install_app(self, app_id: str, star_file_path: str, metadata: Optional[Dict[str, Any]] = None) -> bool: + """ + Install a new Starlark app. + + Args: + app_id: Unique identifier for the app + star_file_path: Path to .star file to install + metadata: Optional metadata (name, description, etc.) + + Returns: + True if successful + """ + try: + import shutil + + # Sanitize app_id to prevent path traversal + safe_app_id = self._sanitize_app_id(app_id) + + # Create app directory with resolved path + app_dir = (self.apps_dir / safe_app_id).resolve() + app_dir.mkdir(parents=True, exist_ok=True) + + # Verify path safety after mkdir + self._verify_path_safety(app_dir, self.apps_dir) + + # Copy .star file with sanitized app_id + star_dest = app_dir / f"{safe_app_id}.star" + # Verify star_dest path safety + self._verify_path_safety(star_dest, self.apps_dir) + shutil.copy2(star_file_path, star_dest) + + # Create app manifest entry + app_manifest = { + "name": metadata.get("name", app_id) if metadata else app_id, + "original_id": app_id, # Store original for reference + "star_file": f"{safe_app_id}.star", + "enabled": True, + "render_interval": metadata.get("render_interval", 300) if metadata else 300, + "display_duration": metadata.get("display_duration", 15) if metadata else 15 + } + + # Try to extract schema + _, schema, _ = self.pixlet.extract_schema(str(star_dest)) + if schema: + schema_path = app_dir / "schema.json" + # Verify schema path safety + self._verify_path_safety(schema_path, self.apps_dir) + with open(schema_path, 'w') as f: + json.dump(schema, f, indent=2) + + # Create default config + default_config = {} + config_path = app_dir / "config.json" + # Verify config path safety + self._verify_path_safety(config_path, self.apps_dir) + with open(config_path, 'w') as f: + json.dump(default_config, f, indent=2) + + # Update manifest (use safe_app_id as key to match directory) + with open(self.manifest_file, 'r') as f: + manifest = json.load(f) + + manifest["apps"][safe_app_id] = app_manifest + self._save_manifest(manifest) + + # Create app instance (use safe_app_id for internal key, original for display) + app = StarlarkApp(safe_app_id, app_dir, app_manifest) + self.apps[safe_app_id] = app + + self.logger.info(f"Installed Starlark app: {app_id} (sanitized: {safe_app_id})") + return True + + except Exception as e: + self.logger.error(f"Error installing app {app_id}: {e}") + return False + + def uninstall_app(self, app_id: str) -> bool: + """ + Uninstall a Starlark app. + + Args: + app_id: App to uninstall + + Returns: + True if successful + """ + try: + import shutil + + if app_id not in self.apps: + self.logger.warning(f"App not found: {app_id}") + return False + + # Remove from current app if selected + if self.current_app and self.current_app.app_id == app_id: + self.current_app = None + + # Remove from apps dict + app = self.apps.pop(app_id) + + # Remove directory + if app.app_dir.exists(): + shutil.rmtree(app.app_dir) + + # Update manifest + with open(self.manifest_file, 'r') as f: + manifest = json.load(f) + + if app_id in manifest["apps"]: + del manifest["apps"][app_id] + self._save_manifest(manifest) + + self.logger.info(f"Uninstalled Starlark app: {app_id}") + return True + + except Exception as e: + self.logger.error(f"Error uninstalling app {app_id}: {e}") + return False + + def get_display_duration(self) -> float: + """Get display duration for current app.""" + if self.current_app: + return float(self.current_app.get_display_duration()) + return self.config.get('display_duration', 15.0) + + def get_info(self) -> Dict[str, Any]: + """Return plugin info for web UI.""" + info = super().get_info() + info.update({ + 'pixlet_available': self.pixlet.is_available(), + 'pixlet_version': self.pixlet.get_version(), + 'installed_apps': len(self.apps), + 'enabled_apps': len([a for a in self.apps.values() if a.is_enabled()]), + 'current_app': self.current_app.app_id if self.current_app else None, + 'apps': { + app_id: { + 'name': app.manifest.get('name', app_id), + 'enabled': app.is_enabled(), + 'has_frames': app.frames is not None + } + for app_id, app in self.apps.items() + } + }) + return info diff --git a/plugin-repos/starlark-apps/manifest.json b/plugin-repos/starlark-apps/manifest.json new file mode 100644 index 000000000..ef44707b8 --- /dev/null +++ b/plugin-repos/starlark-apps/manifest.json @@ -0,0 +1,26 @@ +{ + "id": "starlark-apps", + "name": "Starlark Apps", + "version": "1.0.0", + "author": "LEDMatrix", + "description": "Manages and displays Starlark (.star) apps from Tronbyte/Tidbyt community. Import widgets seamlessly without modification.", + "entry_point": "manager.py", + "class_name": "StarlarkAppsPlugin", + "category": "system", + "tags": [ + "starlark", + "widgets", + "tronbyte", + "tidbyt", + "apps", + "community" + ], + "display_modes": [], + "update_interval": 60, + "default_duration": 15, + "dependencies": [ + "Pillow>=10.0.0", + "PyYAML>=6.0", + "requests>=2.31.0" + ] +} diff --git a/plugin-repos/starlark-apps/pixlet_renderer.py b/plugin-repos/starlark-apps/pixlet_renderer.py new file mode 100644 index 000000000..29805331b --- /dev/null +++ b/plugin-repos/starlark-apps/pixlet_renderer.py @@ -0,0 +1,346 @@ +""" +Pixlet Renderer Module for Starlark Apps + +Handles execution of Pixlet CLI to render .star files into WebP animations. +Supports bundled binaries and system-installed Pixlet. +""" + +import json +import logging +import os +import platform +import shutil +import subprocess +from pathlib import Path +from typing import Dict, Any, Optional, Tuple + +logger = logging.getLogger(__name__) + + +class PixletRenderer: + """ + Wrapper for Pixlet CLI rendering. + + Handles: + - Auto-detection of bundled or system Pixlet binary + - Rendering .star files with configuration + - Schema extraction from .star files + - Timeout and error handling + """ + + def __init__(self, pixlet_path: Optional[str] = None, timeout: int = 30): + """ + Initialize the Pixlet renderer. + + Args: + pixlet_path: Optional explicit path to Pixlet binary + timeout: Maximum seconds to wait for rendering + """ + self.timeout = timeout + self.pixlet_binary = self._find_pixlet_binary(pixlet_path) + + if self.pixlet_binary: + logger.info(f"Pixlet renderer initialized with binary: {self.pixlet_binary}") + else: + logger.warning("Pixlet binary not found - rendering will fail") + + def _find_pixlet_binary(self, explicit_path: Optional[str] = None) -> Optional[str]: + """ + Find Pixlet binary using the following priority: + 1. Explicit path provided + 2. Bundled binary for current architecture + 3. System PATH + + Args: + explicit_path: User-specified path to Pixlet + + Returns: + Path to Pixlet binary, or None if not found + """ + # 1. Check explicit path + if explicit_path and os.path.isfile(explicit_path): + if os.access(explicit_path, os.X_OK): + logger.debug(f"Using explicit Pixlet path: {explicit_path}") + return explicit_path + else: + logger.warning(f"Explicit Pixlet path not executable: {explicit_path}") + + # 2. Check bundled binary + try: + bundled_path = self._get_bundled_binary_path() + if bundled_path and os.path.isfile(bundled_path): + # Ensure executable + if not os.access(bundled_path, os.X_OK): + try: + os.chmod(bundled_path, 0o755) + logger.debug(f"Made bundled binary executable: {bundled_path}") + except OSError: + logger.exception(f"Could not make bundled binary executable: {bundled_path}") + + if os.access(bundled_path, os.X_OK): + logger.debug(f"Using bundled Pixlet binary: {bundled_path}") + return bundled_path + except OSError: + logger.exception("Could not locate bundled binary") + + # 3. Check system PATH + system_pixlet = shutil.which("pixlet") + if system_pixlet: + logger.debug(f"Using system Pixlet: {system_pixlet}") + return system_pixlet + + logger.error("Pixlet binary not found in any location") + return None + + def _get_bundled_binary_path(self) -> Optional[str]: + """ + Get path to bundled Pixlet binary for current architecture. + + Returns: + Path to bundled binary, or None if not found + """ + try: + # Determine project root (parent of plugin-repos) + current_dir = Path(__file__).resolve().parent + project_root = current_dir.parent.parent + bin_dir = project_root / "bin" / "pixlet" + + # Detect architecture + system = platform.system().lower() + machine = platform.machine().lower() + + # Map architecture to binary name + if system == "linux": + if "aarch64" in machine or "arm64" in machine: + binary_name = "pixlet-linux-arm64" + elif "x86_64" in machine or "amd64" in machine: + binary_name = "pixlet-linux-amd64" + else: + logger.warning(f"Unsupported Linux architecture: {machine}") + return None + elif system == "darwin": + if "arm64" in machine: + binary_name = "pixlet-darwin-arm64" + else: + binary_name = "pixlet-darwin-amd64" + elif system == "windows": + binary_name = "pixlet-windows-amd64.exe" + else: + logger.warning(f"Unsupported system: {system}") + return None + + binary_path = bin_dir / binary_name + if binary_path.exists(): + return str(binary_path) + + logger.debug(f"Bundled binary not found at: {binary_path}") + return None + + except OSError: + logger.exception("Error finding bundled binary") + return None + + def _get_safe_working_directory(self, star_file: str) -> Optional[str]: + """ + Get a safe working directory for subprocess execution. + + Args: + star_file: Path to .star file + + Returns: + Resolved parent directory, or None if empty or invalid + """ + try: + resolved_parent = os.path.dirname(os.path.abspath(star_file)) + # Return None if empty string to avoid FileNotFoundError + if not resolved_parent: + logger.debug(f"Empty parent directory for star_file: {star_file}") + return None + return resolved_parent + except (OSError, ValueError): + logger.debug(f"Could not resolve working directory for: {star_file}") + return None + + def is_available(self) -> bool: + """ + Check if Pixlet is available and functional. + + Returns: + True if Pixlet can be executed + """ + if not self.pixlet_binary: + return False + + try: + result = subprocess.run( + [self.pixlet_binary, "version"], + capture_output=True, + text=True, + timeout=5 + ) + return result.returncode == 0 + except subprocess.TimeoutExpired: + logger.debug("Pixlet version check timed out") + return False + except (subprocess.SubprocessError, OSError): + logger.exception("Pixlet not available") + return False + + def get_version(self) -> Optional[str]: + """ + Get Pixlet version string. + + Returns: + Version string, or None if unavailable + """ + if not self.pixlet_binary: + return None + + try: + result = subprocess.run( + [self.pixlet_binary, "version"], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + return result.stdout.strip() + except subprocess.TimeoutExpired: + logger.debug("Pixlet version check timed out") + except (subprocess.SubprocessError, OSError): + logger.exception("Could not get Pixlet version") + + return None + + def render( + self, + star_file: str, + output_path: str, + config: Optional[Dict[str, Any]] = None, + magnify: int = 1 + ) -> Tuple[bool, Optional[str]]: + """ + Render a .star file to WebP output. + + Args: + star_file: Path to .star file + output_path: Where to save WebP output + config: Configuration dictionary to pass to app + magnify: Magnification factor (default 1) + + Returns: + Tuple of (success: bool, error_message: Optional[str]) + """ + if not self.pixlet_binary: + return False, "Pixlet binary not found" + + if not os.path.isfile(star_file): + return False, f"Star file not found: {star_file}" + + try: + # Build command + cmd = [ + self.pixlet_binary, + "render", + star_file, + "-o", output_path, + "-m", str(magnify) + ] + + # Add configuration parameters + if config: + for key, value in config.items(): + # Convert value to string for CLI + if isinstance(value, bool): + value_str = "true" if value else "false" + else: + value_str = str(value) + cmd.extend(["-c", f"{key}={value_str}"]) + + logger.debug(f"Executing Pixlet: {' '.join(cmd)}") + + # Execute rendering + safe_cwd = self._get_safe_working_directory(star_file) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.timeout, + cwd=safe_cwd # Run in .star file directory (or None if relative path) + ) + + if result.returncode == 0: + if os.path.isfile(output_path): + logger.debug(f"Successfully rendered: {star_file} -> {output_path}") + return True, None + else: + error = "Rendering succeeded but output file not found" + logger.error(error) + return False, error + else: + error = f"Pixlet failed (exit {result.returncode}): {result.stderr}" + logger.error(error) + return False, error + + except subprocess.TimeoutExpired: + error = f"Rendering timeout after {self.timeout}s" + logger.error(error) + return False, error + except (subprocess.SubprocessError, OSError): + logger.exception("Rendering exception") + return False, "Rendering failed - see logs for details" + + def extract_schema(self, star_file: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: + """ + Extract configuration schema from a .star file. + + Args: + star_file: Path to .star file + + Returns: + Tuple of (success: bool, schema: Optional[Dict], error: Optional[str]) + """ + if not self.pixlet_binary: + return False, None, "Pixlet binary not found" + + if not os.path.isfile(star_file): + return False, None, f"Star file not found: {star_file}" + + try: + # Use 'pixlet info' or 'pixlet serve' to extract schema + # Note: Schema extraction may vary by Pixlet version + cmd = [self.pixlet_binary, "serve", star_file, "--print-schema"] + + logger.debug(f"Extracting schema: {' '.join(cmd)}") + + safe_cwd = self._get_safe_working_directory(star_file) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10, + cwd=safe_cwd # Run in .star file directory (or None if relative path) + ) + + if result.returncode == 0: + # Parse JSON schema from output + try: + schema = json.loads(result.stdout) + logger.debug(f"Extracted schema from: {star_file}") + return True, schema, None + except json.JSONDecodeError as e: + error = f"Invalid schema JSON: {e}" + logger.warning(error) + return False, None, error + else: + # Schema extraction might not be supported + logger.debug(f"Schema extraction not available or failed: {result.stderr}") + return True, None, None # Not an error, just no schema + + except subprocess.TimeoutExpired: + error = "Schema extraction timeout" + logger.warning(error) + return False, None, error + except (subprocess.SubprocessError, OSError): + logger.exception("Schema extraction exception") + return False, None, "Schema extraction failed - see logs for details" diff --git a/plugin-repos/starlark-apps/tronbyte_repository.py b/plugin-repos/starlark-apps/tronbyte_repository.py new file mode 100644 index 000000000..7795a15a3 --- /dev/null +++ b/plugin-repos/starlark-apps/tronbyte_repository.py @@ -0,0 +1,366 @@ +""" +Tronbyte Repository Module + +Handles interaction with the Tronbyte apps repository on GitHub. +Fetches app listings, metadata, and downloads .star files. +""" + +import logging +import requests +import yaml +from typing import Dict, Any, Optional, List, Tuple +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class TronbyteRepository: + """ + Interface to the Tronbyte apps repository. + + Provides methods to: + - List available apps + - Fetch app metadata + - Download .star files + - Parse manifest.yaml files + """ + + REPO_OWNER = "tronbyt" + REPO_NAME = "apps" + DEFAULT_BRANCH = "main" + APPS_PATH = "apps" + + def __init__(self, github_token: Optional[str] = None): + """ + Initialize repository interface. + + Args: + github_token: Optional GitHub personal access token for higher rate limits + """ + self.github_token = github_token + self.base_url = "https://api.github.com" + self.raw_url = "https://raw.githubusercontent.com" + + self.session = requests.Session() + if github_token: + self.session.headers.update({ + 'Authorization': f'token {github_token}' + }) + self.session.headers.update({ + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'LEDMatrix-Starlark-Plugin' + }) + + def _make_request(self, url: str, timeout: int = 10) -> Optional[Dict[str, Any]]: + """ + Make a request to GitHub API with error handling. + + Args: + url: API URL to request + timeout: Request timeout in seconds + + Returns: + JSON response or None on error + """ + try: + response = self.session.get(url, timeout=timeout) + + if response.status_code == 403: + # Rate limit exceeded + logger.warning("GitHub API rate limit exceeded") + return None + elif response.status_code == 404: + logger.warning(f"Resource not found: {url}") + return None + elif response.status_code != 200: + logger.error(f"GitHub API error: {response.status_code}") + return None + + return response.json() + + except requests.Timeout: + logger.error(f"Request timeout: {url}") + return None + except requests.RequestException as e: + logger.error(f"Request error: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error: {e}") + return None + + def _fetch_raw_file(self, file_path: str, branch: Optional[str] = None) -> Optional[str]: + """ + Fetch raw file content from repository. + + Args: + file_path: Path to file in repository + branch: Branch name (default: DEFAULT_BRANCH) + + Returns: + File content as string, or None on error + """ + branch = branch or self.DEFAULT_BRANCH + url = f"{self.raw_url}/{self.REPO_OWNER}/{self.REPO_NAME}/{branch}/{file_path}" + + try: + response = self.session.get(url, timeout=10) + if response.status_code == 200: + return response.text + else: + logger.warning(f"Failed to fetch raw file: {file_path} ({response.status_code})") + return None + except Exception as e: + logger.error(f"Error fetching raw file {file_path}: {e}") + return None + + def list_apps(self) -> Tuple[bool, Optional[List[Dict[str, Any]]], Optional[str]]: + """ + List all available apps in the repository. + + Returns: + Tuple of (success, apps_list, error_message) + """ + url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}" + + data = self._make_request(url) + if data is None: + return False, None, "Failed to fetch repository contents" + + if not isinstance(data, list): + return False, None, "Invalid response format" + + # Filter directories (apps) + apps = [] + for item in data: + if item.get('type') == 'dir': + app_id = item.get('name') + if app_id and not app_id.startswith('.'): + apps.append({ + 'id': app_id, + 'path': item.get('path'), + 'url': item.get('url') + }) + + logger.info(f"Found {len(apps)} apps in repository") + return True, apps, None + + def get_app_metadata(self, app_id: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: + """ + Fetch metadata for a specific app. + + Reads the manifest.yaml file for the app and parses it. + + Args: + app_id: App identifier + + Returns: + Tuple of (success, metadata_dict, error_message) + """ + manifest_path = f"{self.APPS_PATH}/{app_id}/manifest.yaml" + + content = self._fetch_raw_file(manifest_path) + if not content: + return False, None, f"Failed to fetch manifest for {app_id}" + + try: + metadata = yaml.safe_load(content) + + # Validate that metadata is a dict before mutating + if not isinstance(metadata, dict): + if metadata is None: + logger.warning(f"Manifest for {app_id} is empty or None, initializing empty dict") + metadata = {} + else: + logger.error(f"Manifest for {app_id} is not a dict (got {type(metadata).__name__}), skipping") + return False, None, f"Invalid manifest format: expected dict, got {type(metadata).__name__}" + + # Enhance with app_id + metadata['id'] = app_id + + # Parse schema if present + if 'schema' in metadata: + # Schema is already parsed from YAML + pass + + return True, metadata, None + + except (yaml.YAMLError, TypeError) as e: + logger.error(f"Failed to parse manifest for {app_id}: {e}") + return False, None, f"Invalid manifest format: {e}" + + def list_apps_with_metadata(self, max_apps: Optional[int] = None) -> List[Dict[str, Any]]: + """ + List all apps with their metadata. + + This is slower as it fetches manifest.yaml for each app. + + Args: + max_apps: Optional limit on number of apps to fetch + + Returns: + List of app metadata dictionaries + """ + success, apps, error = self.list_apps() + + if not success: + logger.error(f"Failed to list apps: {error}") + return [] + + if max_apps is not None: + apps = apps[:max_apps] + + apps_with_metadata = [] + for app_info in apps: + app_id = app_info['id'] + success, metadata, error = self.get_app_metadata(app_id) + + if success and metadata: + # Merge basic info with metadata + metadata.update({ + 'repository_path': app_info['path'] + }) + apps_with_metadata.append(metadata) + else: + # Add basic info even if metadata fetch failed + apps_with_metadata.append({ + 'id': app_id, + 'name': app_id.replace('_', ' ').title(), + 'summary': 'No description available', + 'repository_path': app_info['path'], + 'metadata_error': error + }) + + return apps_with_metadata + + def download_star_file(self, app_id: str, output_path: Path) -> Tuple[bool, Optional[str]]: + """ + Download the .star file for an app. + + Args: + app_id: App identifier + output_path: Where to save the .star file + + Returns: + Tuple of (success, error_message) + """ + star_path = f"{self.APPS_PATH}/{app_id}/{app_id}.star" + + content = self._fetch_raw_file(star_path) + if not content: + return False, f"Failed to download .star file for {app_id}" + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, 'w', encoding='utf-8') as f: + f.write(content) + + logger.info(f"Downloaded {app_id}.star to {output_path}") + return True, None + + except OSError as e: + logger.exception(f"Failed to save .star file: {e}") + return False, f"Failed to save file: {e}" + + def get_app_files(self, app_id: str) -> Tuple[bool, Optional[List[str]], Optional[str]]: + """ + List all files in an app directory. + + Args: + app_id: App identifier + + Returns: + Tuple of (success, file_list, error_message) + """ + url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}/{app_id}" + + data = self._make_request(url) + if not data: + return False, None, "Failed to fetch app files" + + if not isinstance(data, list): + return False, None, "Invalid response format" + + files = [item['name'] for item in data if item.get('type') == 'file'] + return True, files, None + + def search_apps(self, query: str, apps_with_metadata: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Search apps by name, summary, or description. + + Args: + query: Search query string + apps_with_metadata: List of apps with metadata + + Returns: + Filtered list of apps matching query + """ + if not query: + return apps_with_metadata + + query_lower = query.lower() + results = [] + + for app in apps_with_metadata: + # Search in name, summary, description, author + searchable = ' '.join([ + app.get('name', ''), + app.get('summary', ''), + app.get('desc', ''), + app.get('author', ''), + app.get('id', '') + ]).lower() + + if query_lower in searchable: + results.append(app) + + return results + + def filter_by_category(self, category: str, apps_with_metadata: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Filter apps by category. + + Args: + category: Category name (or 'all' for no filtering) + apps_with_metadata: List of apps with metadata + + Returns: + Filtered list of apps + """ + if not category or category.lower() == 'all': + return apps_with_metadata + + category_lower = category.lower() + results = [] + + for app in apps_with_metadata: + app_category = app.get('category', '').lower() + if app_category == category_lower: + results.append(app) + + return results + + def get_rate_limit_info(self) -> Dict[str, Any]: + """ + Get current GitHub API rate limit information. + + Returns: + Dictionary with rate limit info + """ + url = f"{self.base_url}/rate_limit" + data = self._make_request(url) + + if data: + core = data.get('resources', {}).get('core', {}) + return { + 'limit': core.get('limit', 0), + 'remaining': core.get('remaining', 0), + 'reset': core.get('reset', 0), + 'used': core.get('used', 0) + } + + return { + 'limit': 0, + 'remaining': 0, + 'reset': 0, + 'used': 0 + } diff --git a/scripts/download_pixlet.sh b/scripts/download_pixlet.sh new file mode 100755 index 000000000..b3d4070aa --- /dev/null +++ b/scripts/download_pixlet.sh @@ -0,0 +1,139 @@ +#!/bin/bash +# +# Download Pixlet binaries for bundled distribution +# +# This script downloads Pixlet binaries from the Tronbyte fork +# for multiple architectures to support various platforms. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +BIN_DIR="$PROJECT_ROOT/bin/pixlet" + +# Pixlet version to download (use 'latest' to auto-detect) +PIXLET_VERSION="${PIXLET_VERSION:-latest}" + +# GitHub repository (Tronbyte fork) +REPO="tronbyt/pixlet" + +echo "========================================" +echo "Pixlet Binary Download Script" +echo "========================================" + +# Auto-detect latest version if needed +if [ "$PIXLET_VERSION" = "latest" ]; then + echo "Detecting latest version..." + PIXLET_VERSION=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') + if [ -z "$PIXLET_VERSION" ]; then + echo "Failed to detect latest version, using fallback" + PIXLET_VERSION="v0.50.2" + fi +fi + +echo "Version: $PIXLET_VERSION" +echo "Target directory: $BIN_DIR" +echo "" + +# Create bin directory if it doesn't exist +mkdir -p "$BIN_DIR" + +# New naming convention: pixlet_v0.50.2_linux-arm64.tar.gz +# Only download ARM64 Linux binary for Raspberry Pi +declare -A ARCHITECTURES=( + ["linux-arm64"]="pixlet_${PIXLET_VERSION}_linux-arm64.tar.gz" +) + +download_binary() { + local arch="$1" + local archive_name="$2" + local binary_name="pixlet-${arch}" + + local output_path="$BIN_DIR/$binary_name" + + # Skip if already exists + if [ -f "$output_path" ]; then + echo "✓ $binary_name already exists, skipping..." + return 0 + fi + + echo "→ Downloading $arch..." + + # Construct download URL + local url="https://github.com/${REPO}/releases/download/${PIXLET_VERSION}/${archive_name}" + + # Download to temp directory (use project-local temp to avoid /tmp permission issues) + local temp_dir + temp_dir=$(mktemp -d -p "$PROJECT_ROOT" -t pixlet_download.XXXXXXXXXX) + local temp_file="$temp_dir/$archive_name" + + if ! curl -L -o "$temp_file" "$url" 2>/dev/null; then + echo "✗ Failed to download $arch" + rm -rf "$temp_dir" + return 1 + fi + + # Extract binary + echo " Extracting..." + if ! tar -xzf "$temp_file" -C "$temp_dir"; then + echo "✗ Failed to extract archive: $temp_file" + rm -rf "$temp_dir" + return 1 + fi + + # Find the pixlet binary in extracted files + local extracted_binary + extracted_binary=$(find "$temp_dir" -name "pixlet" | head -n 1) + + if [ -z "$extracted_binary" ]; then + echo "✗ Binary not found in archive" + rm -rf "$temp_dir" + return 1 + fi + + # Move to final location + mv "$extracted_binary" "$output_path" + + # Make executable + chmod +x "$output_path" + + # Clean up + rm -rf "$temp_dir" + + # Verify + local size + size=$(stat -f%z "$output_path" 2>/dev/null || stat -c%s "$output_path" 2>/dev/null || echo "unknown") + if [ "$size" = "unknown" ]; then + echo "✓ Downloaded $binary_name" + else + echo "✓ Downloaded $binary_name ($(numfmt --to=iec-i --suffix=B $size 2>/dev/null || echo "${size} bytes"))" + fi + + return 0 +} + +# Download binaries for each architecture +success_count=0 +total_count=${#ARCHITECTURES[@]} + +for arch in "${!ARCHITECTURES[@]}"; do + if download_binary "$arch" "${ARCHITECTURES[$arch]}"; then + ((success_count++)) + fi +done + +echo "" +echo "========================================" +echo "Download complete: $success_count/$total_count succeeded" +echo "========================================" + +# List downloaded binaries +echo "" +echo "Installed binaries:" +if compgen -G "$BIN_DIR/*" > /dev/null 2>&1; then + ls -lh "$BIN_DIR"/* +else + echo "No binaries found" +fi + +exit 0 diff --git a/scripts/utils/start_web_conditionally.py b/scripts/utils/start_web_conditionally.py index 0c79efa6d..11debc398 100644 --- a/scripts/utils/start_web_conditionally.py +++ b/scripts/utils/start_web_conditionally.py @@ -11,13 +11,12 @@ def install_dependencies(): """Install required dependencies using system Python.""" - print("Installing dependencies...") + print("Checking dependencies...") try: requirements_file = os.path.join(PROJECT_DIR, 'web_interface', 'requirements.txt') - # Use --ignore-installed to handle system packages (like psutil) that can't be uninstalled - # This allows pip to install even if a system package version conflicts + # First try to install normally (only installs missing packages) result = subprocess.run([ - sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--ignore-installed', '-r', requirements_file + sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', requirements_file ], capture_output=True, text=True) if result.returncode != 0: @@ -35,7 +34,7 @@ def install_dependencies(): f.writelines(filtered_lines) try: subprocess.check_call([ - sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--ignore-installed', '-r', temp_reqs + sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', temp_reqs ]) print("Dependencies installed successfully (psutil skipped - using system version)") finally: diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 381b0d627..24b2675dd 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -9,6 +9,7 @@ import logging from datetime import datetime from pathlib import Path +from typing import Tuple, Optional logger = logging.getLogger(__name__) @@ -375,11 +376,6 @@ def save_main_config(): if not data: return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - import logging - logging.error(f"DEBUG: save_main_config received data: {data}") - logging.error(f"DEBUG: Content-Type header: {request.content_type}") - logging.error(f"DEBUG: Headers: {dict(request.headers)}") - # Merge with existing config (similar to original implementation) current_config = api_v3.config_manager.load_config() @@ -1452,6 +1448,30 @@ def get_installed_plugins(): 'web_ui_actions': web_ui_actions }) + # Add starlark apps as virtual plugins + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + if starlark_plugin and hasattr(starlark_plugin, 'apps'): + for app_id, app in starlark_plugin.apps.items(): + # Create virtual plugin entry for each starlark app + plugins.append({ + 'id': f'starlark:{app_id}', # Prefix to identify as starlark app + 'name': app.manifest.get('name', app_id), + 'author': app.manifest.get('author', 'Tronbyte Community'), + 'category': 'Starlark App', + 'description': app.manifest.get('summary', app.manifest.get('desc', 'Starlark app from Tronbyte repository')), + 'tags': ['starlark', app.manifest.get('category', 'other')], + 'enabled': app.is_enabled(), + 'verified': True, # Tronbyte apps are community verified + 'loaded': True, + 'last_updated': None, + 'last_commit': None, + 'last_commit_message': None, + 'branch': None, + 'web_ui_actions': [], + 'is_starlark_app': True, # Flag to identify starlark apps + 'starlark_app_id': app_id # Original app ID for API calls + }) + return jsonify({'status': 'success', 'data': {'plugins': plugins}}) except Exception as e: import traceback @@ -1727,6 +1747,34 @@ def toggle_plugin(): current_enabled = config.get(plugin_id, {}).get('enabled', False) enabled = not current_enabled + # Handle starlark apps (prefixed with 'starlark:') + if plugin_id.startswith('starlark:'): + starlark_app_id = plugin_id.replace('starlark:', '', 1) + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + + if not starlark_plugin: + return jsonify({'status': 'error', 'message': 'Starlark Apps plugin not loaded'}), 404 + + app = starlark_plugin.apps.get(starlark_app_id) + if not app: + return jsonify({'status': 'error', 'message': f'Starlark app {starlark_app_id} not found'}), 404 + + # Update manifest + app.manifest['enabled'] = enabled + + # Save manifest + with open(starlark_plugin.manifest_file, 'r') as f: + manifest = json.load(f) + + manifest['apps'][starlark_app_id]['enabled'] = enabled + starlark_plugin._save_manifest(manifest) + + return jsonify({ + 'status': 'success', + 'message': f"Starlark app {'enabled' if enabled else 'disabled'}", + 'enabled': enabled + }) + # Check if plugin exists in manifests (discovered but may not be loaded) if plugin_id not in api_v3.plugin_manager.plugin_manifests: return jsonify({'status': 'error', 'message': f'Plugin {plugin_id} not found'}), 404 @@ -4068,18 +4116,8 @@ def separate_secrets(config, secrets_set, prefix=''): if plugin_id not in current_config: current_config[plugin_id] = {} - # Debug logging for live_priority before merge - if plugin_id == 'football-scoreboard': - print(f"[DEBUG] Before merge - current NFL live_priority: {current_config[plugin_id].get('nfl', {}).get('live_priority')}") - print(f"[DEBUG] Before merge - regular_config NFL live_priority: {regular_config.get('nfl', {}).get('live_priority')}") - current_config[plugin_id] = deep_merge(current_config[plugin_id], regular_config) - # Debug logging for live_priority after merge - if plugin_id == 'football-scoreboard': - print(f"[DEBUG] After merge - NFL live_priority: {current_config[plugin_id].get('nfl', {}).get('live_priority')}") - print(f"[DEBUG] After merge - NCAA FB live_priority: {current_config[plugin_id].get('ncaa_fb', {}).get('live_priority')}") - # Deep merge plugin secrets in secrets config if secrets_config: if plugin_id not in current_secrets: @@ -5966,4 +6004,1022 @@ def delete_cache_file(): error_details = traceback.format_exc() print(f"Error in delete_cache_file: {str(e)}") print(error_details) - return jsonify({'status': 'error', 'message': str(e)}), 500 \ No newline at end of file + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +# ============================================================================ +# Starlark Apps API Endpoints +# ============================================================================ + +@api_v3.route('/starlark/status', methods=['GET']) +def get_starlark_status(): + """Get Starlark plugin status and Pixlet availability.""" + try: + # Guard: check if plugin_manager is initialized + if not api_v3.plugin_manager: + return jsonify({ + 'status': 'error', + 'message': 'Plugin manager not initialized', + 'pixlet_available': False + }), 500 + + # Get the starlark-apps plugin instance (only available if loaded) + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + + if starlark_plugin: + # Plugin is loaded - get full info + info = starlark_plugin.get_info() + magnify_info = starlark_plugin.get_magnify_recommendation() + + return jsonify({ + 'status': 'success', + 'pixlet_available': info.get('pixlet_available', False), + 'pixlet_version': info.get('pixlet_version'), + 'installed_apps': info.get('installed_apps', 0), + 'enabled_apps': info.get('enabled_apps', 0), + 'current_app': info.get('current_app'), + 'plugin_enabled': starlark_plugin.enabled, + 'display_info': magnify_info + }) + + # Plugin not loaded - check if it's at least installed + plugin_info = api_v3.plugin_manager.get_plugin_info('starlark-apps') + + if not plugin_info: + return jsonify({ + 'status': 'error', + 'message': 'Starlark Apps plugin not installed', + 'pixlet_available': False + }), 404 + + # Plugin is installed but not loaded - check Pixlet availability directly + import shutil + import platform + from pathlib import Path + + # Check for pixlet binary in bundled location (bin/pixlet/) + project_root = Path(__file__).parent.parent.parent + bin_dir = project_root / 'bin' / 'pixlet' + + # Detect architecture and find the right binary + system = platform.system().lower() + machine = platform.machine().lower() + + pixlet_binary = None + if system == "linux": + if "aarch64" in machine or "arm64" in machine: + pixlet_binary = bin_dir / "pixlet-linux-arm64" + elif "x86_64" in machine or "amd64" in machine: + pixlet_binary = bin_dir / "pixlet-linux-amd64" + elif system == "darwin": + if "arm64" in machine: + pixlet_binary = bin_dir / "pixlet-darwin-arm64" + else: + pixlet_binary = bin_dir / "pixlet-darwin-amd64" + + # Check bundled binary or system PATH + pixlet_available = (pixlet_binary and pixlet_binary.exists()) or shutil.which('pixlet') is not None + + # Get pixlet version if available + pixlet_version = None + if pixlet_available: + try: + import subprocess + binary_to_use = str(pixlet_binary) if (pixlet_binary and pixlet_binary.exists()) else 'pixlet' + result = subprocess.run([binary_to_use, 'version'], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + pixlet_version = result.stdout.strip() + except Exception: + pass + + return jsonify({ + 'status': 'success', + 'pixlet_available': pixlet_available, + 'pixlet_version': pixlet_version, + 'installed_apps': 0, + 'enabled_apps': 0, + 'current_app': None, + 'plugin_enabled': plugin_info.get('enabled', False), + 'plugin_loaded': False, + 'display_info': {} + }) + + except Exception as e: + logger.error(f"Error getting starlark status: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@api_v3.route('/starlark/apps', methods=['GET']) +def get_starlark_apps(): + """List all installed Starlark apps.""" + try: + # Guard: check if plugin_manager is initialized + if not api_v3.plugin_manager: + return jsonify({ + 'status': 'error', + 'message': 'Plugin manager not initialized', + 'pixlet_available': False + }), 500 + + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + + if not starlark_plugin: + # Plugin not loaded - return empty list instead of error + # This happens when Pixlet isn't installed yet + return jsonify({ + 'status': 'success', + 'apps': [], + 'count': 0, + 'message': 'Plugin not loaded - install Pixlet first' + }) + + # Get plugin info which includes apps list + info = starlark_plugin.get_info() + apps = info.get('apps', {}) + + # Format apps for UI + apps_list = [] + for app_id, app_data in apps.items(): + app_instance = starlark_plugin.apps.get(app_id) + if app_instance: + apps_list.append({ + 'id': app_id, + 'name': app_data.get('name', app_id), + 'enabled': app_data.get('enabled', True), + 'has_frames': app_data.get('has_frames', False), + 'render_interval': app_instance.get_render_interval(), + 'display_duration': app_instance.get_display_duration(), + 'config': app_instance.config, + 'has_schema': app_instance.schema is not None, + 'last_render_time': app_instance.last_render_time + }) + + return jsonify({ + 'status': 'success', + 'apps': apps_list, + 'count': len(apps_list) + }) + + except Exception as e: + logger.error(f"Error getting starlark apps: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@api_v3.route('/starlark/apps/', methods=['GET']) +def get_starlark_app(app_id): + """Get details for a specific Starlark app.""" + try: + # Guard: check if plugin_manager is initialized + if not api_v3.plugin_manager: + return jsonify({ + 'status': 'error', + 'message': 'Plugin manager not initialized', + 'pixlet_available': False + }), 500 + + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + + if not starlark_plugin: + return jsonify({ + 'status': 'error', + 'message': 'Starlark Apps plugin not installed' + }), 404 + + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({ + 'status': 'error', + 'message': f'App not found: {app_id}' + }), 404 + + return jsonify({ + 'status': 'success', + 'app': { + 'id': app_id, + 'name': app.manifest.get('name', app_id), + 'enabled': app.is_enabled(), + 'config': app.config, + 'schema': app.schema, + 'render_interval': app.get_render_interval(), + 'display_duration': app.get_display_duration(), + 'has_frames': app.frames is not None, + 'frame_count': len(app.frames) if app.frames else 0, + 'last_render_time': app.last_render_time, + 'star_file': str(app.star_file) + } + }) + + except Exception as e: + logger.error(f"Error getting starlark app {app_id}: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +def _validate_and_sanitize_app_id(app_id: Optional[str], fallback_source: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]: + """ + Validate and sanitize app_id to a safe slug. + + Args: + app_id: App ID to validate (can be None) + fallback_source: Source to generate app_id from if app_id is None/empty + + Returns: + Tuple of (sanitized_app_id, error_message) + If error_message is not None, validation failed + """ + import re + import hashlib + + # If app_id is not provided, generate from fallback_source + if not app_id and fallback_source: + app_id = fallback_source + + if not app_id: + return None, "app_id is required" + + # Check for path traversal attempts + if '..' in app_id or '/' in app_id or '\\' in app_id: + return None, "app_id contains invalid characters (path separators or '..')" + + # Normalize to lowercase + normalized = app_id.lower() + + # Replace invalid characters with underscore + # Allow only: lowercase letters, digits, underscore + sanitized = re.sub(r'[^a-z0-9_]', '_', normalized) + + # Remove leading/trailing underscores + sanitized = sanitized.strip('_') + + # Ensure it's not empty after sanitization + if not sanitized: + # Generate a safe fallback slug from hash + hash_slug = hashlib.sha256(app_id.encode()).hexdigest()[:12] + sanitized = f"app_{hash_slug}" + + # Ensure it doesn't start with a number + if sanitized and sanitized[0].isdigit(): + sanitized = f"app_{sanitized}" + + return sanitized, None + + +def _validate_timing_value(value, field_name: str, min_val: int = 1, max_val: int = 86400) -> Tuple[Optional[int], Optional[str]]: + """ + Validate and coerce timing values (render_interval, display_duration). + + Args: + value: Value to validate (can be None, int, or string) + field_name: Name of the field for error messages + min_val: Minimum allowed value + max_val: Maximum allowed value + + Returns: + Tuple of (validated_int_value, error_message) + If error_message is not None, validation failed + """ + if value is None: + return None, None + + # Try to convert to int + try: + if isinstance(value, str): + int_value = int(value) + else: + int_value = int(value) + except (ValueError, TypeError): + return None, f"{field_name} must be an integer" + + # Check bounds + if int_value < min_val: + return None, f"{field_name} must be at least {min_val}" + + if int_value > max_val: + return None, f"{field_name} must be at most {max_val}" + + return int_value, None + + +@api_v3.route('/starlark/upload', methods=['POST']) +def upload_starlark_app(): + """Upload and install a new Starlark app.""" + try: + # Guard: check if plugin_manager is initialized + if not api_v3.plugin_manager: + return jsonify({ + 'status': 'error', + 'message': 'Plugin manager not initialized', + 'pixlet_available': False + }), 500 + + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + + if not starlark_plugin: + return jsonify({ + 'status': 'error', + 'message': 'Starlark Apps plugin not installed' + }), 404 + + # Check if file was uploaded + if 'file' not in request.files: + return jsonify({ + 'status': 'error', + 'message': 'No file uploaded' + }), 400 + + file = request.files['file'] + + if file.filename == '': + return jsonify({ + 'status': 'error', + 'message': 'No file selected' + }), 400 + + # Validate .star file extension + if not file.filename.endswith('.star'): + return jsonify({ + 'status': 'error', + 'message': 'File must have .star extension' + }), 400 + + # Get optional metadata + app_name = request.form.get('name') + app_id_input = request.form.get('app_id') + + # Validate and sanitize app_id + # Generate from filename if not provided + filename_base = file.filename.replace('.star', '') if file.filename else None + app_id, app_id_error = _validate_and_sanitize_app_id(app_id_input, fallback_source=filename_base) + if app_id_error: + return jsonify({ + 'status': 'error', + 'message': f'Invalid app_id: {app_id_error}' + }), 400 + + # Validate render_interval (get raw value, no type coercion) + render_interval_input = request.form.get('render_interval') + if render_interval_input is None: + render_interval = 300 # Default when field is missing + else: + render_interval, render_error = _validate_timing_value( + render_interval_input, 'render_interval', min_val=1, max_val=86400 + ) + if render_error: + return jsonify({ + 'status': 'error', + 'message': render_error + }), 400 + if render_interval is None: + render_interval = 300 # Default when validation returns None + + # Validate display_duration (get raw value, no type coercion) + display_duration_input = request.form.get('display_duration') + if display_duration_input is None: + display_duration = 15 # Default when field is missing + else: + display_duration, duration_error = _validate_timing_value( + display_duration_input, 'display_duration', min_val=1, max_val=86400 + ) + if duration_error: + return jsonify({ + 'status': 'error', + 'message': duration_error + }), 400 + if display_duration is None: + display_duration = 15 # Default when validation returns None + + # Save file temporarily + import tempfile + with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp: + file.save(tmp.name) + temp_path = tmp.name + + try: + # Install the app + metadata = { + 'name': app_name or app_id, + 'render_interval': render_interval, + 'display_duration': display_duration + } + + success = starlark_plugin.install_app(app_id, temp_path, metadata) + + if success: + return jsonify({ + 'status': 'success', + 'message': f'App installed successfully: {app_id}', + 'app_id': app_id + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Failed to install app' + }), 500 + + finally: + # Clean up temp file + try: + os.unlink(temp_path) + except OSError as e: + logger.warning(f"Failed to clean up temp file {temp_path}: {e}") + + except Exception as e: + logger.error(f"Error uploading starlark app: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@api_v3.route('/starlark/apps/', methods=['DELETE']) +def uninstall_starlark_app(app_id): + """Uninstall a Starlark app.""" + try: + # Guard: check if plugin_manager is initialized + if not api_v3.plugin_manager: + return jsonify({ + 'status': 'error', + 'message': 'Plugin manager not initialized', + 'pixlet_available': False + }), 500 + + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + + if not starlark_plugin: + return jsonify({ + 'status': 'error', + 'message': 'Starlark Apps plugin not installed' + }), 404 + + success = starlark_plugin.uninstall_app(app_id) + + if success: + return jsonify({ + 'status': 'success', + 'message': f'App uninstalled: {app_id}' + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Failed to uninstall app' + }), 500 + + except Exception as e: + logger.error(f"Error uninstalling starlark app {app_id}: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@api_v3.route('/starlark/apps//config', methods=['GET']) +def get_starlark_app_config(app_id): + """Get configuration for a Starlark app.""" + try: + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + + if not starlark_plugin: + return jsonify({ + 'status': 'error', + 'message': 'Starlark Apps plugin not installed' + }), 404 + + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({ + 'status': 'error', + 'message': f'App not found: {app_id}' + }), 404 + + return jsonify({ + 'status': 'success', + 'config': app.config, + 'schema': app.schema + }) + + except Exception as e: + logger.error(f"Error getting config for {app_id}: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@api_v3.route('/starlark/apps//config', methods=['PUT']) +def update_starlark_app_config(app_id): + """Update configuration for a Starlark app.""" + try: + # Guard: check if plugin_manager is initialized + if not api_v3.plugin_manager: + return jsonify({ + 'status': 'error', + 'message': 'Plugin manager not initialized', + 'pixlet_available': False + }), 500 + + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + + if not starlark_plugin: + return jsonify({ + 'status': 'error', + 'message': 'Starlark Apps plugin not installed' + }), 404 + + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({ + 'status': 'error', + 'message': f'App not found: {app_id}' + }), 404 + + data = request.get_json() + if not data: + return jsonify({ + 'status': 'error', + 'message': 'No configuration provided' + }), 400 + + # Validate timing values if present + if 'render_interval' in data: + render_interval_input = data['render_interval'] + # Reject None/null values - must provide a valid integer + if render_interval_input is None: + return jsonify({ + 'status': 'error', + 'message': 'render_interval cannot be null' + }), 400 + render_interval, render_error = _validate_timing_value( + render_interval_input, 'render_interval', min_val=1, max_val=86400 + ) + if render_error: + return jsonify({ + 'status': 'error', + 'message': render_error + }), 400 + # render_interval should always be set after successful validation + data['render_interval'] = render_interval + + if 'display_duration' in data: + display_duration_input = data['display_duration'] + # Reject None/null values - must provide a valid integer + if display_duration_input is None: + return jsonify({ + 'status': 'error', + 'message': 'display_duration cannot be null' + }), 400 + display_duration, duration_error = _validate_timing_value( + display_duration_input, 'display_duration', min_val=1, max_val=86400 + ) + if duration_error: + return jsonify({ + 'status': 'error', + 'message': duration_error + }), 400 + # display_duration should always be set after successful validation + data['display_duration'] = display_duration + + # Update config with validated data + app.config.update(data) + + # Save to file + if app.save_config(): + # Force re-render with new config + starlark_plugin._render_app(app, force=True) + + return jsonify({ + 'status': 'success', + 'message': 'Configuration updated', + 'config': app.config + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Failed to save configuration' + }), 500 + + except Exception as e: + logger.error(f"Error updating config for {app_id}: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@api_v3.route('/starlark/apps//toggle', methods=['POST']) +def toggle_starlark_app(app_id): + """Enable or disable a Starlark app.""" + try: + # Guard: check if plugin_manager is initialized + if not api_v3.plugin_manager: + return jsonify({ + 'status': 'error', + 'message': 'Plugin manager not initialized', + 'pixlet_available': False + }), 500 + + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + + if not starlark_plugin: + return jsonify({ + 'status': 'error', + 'message': 'Starlark Apps plugin not installed' + }), 404 + + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({ + 'status': 'error', + 'message': f'App not found: {app_id}' + }), 404 + + data = request.get_json() or {} + enabled = data.get('enabled') + + if enabled is None: + # Toggle current state + enabled = not app.is_enabled() + + # Update manifest + app.manifest['enabled'] = enabled + + # Save manifest + with open(starlark_plugin.manifest_file, 'r') as f: + manifest = json.load(f) + + manifest['apps'][app_id]['enabled'] = enabled + starlark_plugin._save_manifest(manifest) + + return jsonify({ + 'status': 'success', + 'message': f"App {'enabled' if enabled else 'disabled'}", + 'enabled': enabled + }) + + except Exception as e: + logger.error(f"Error toggling app {app_id}: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@api_v3.route('/starlark/apps//render', methods=['POST']) +def render_starlark_app(app_id): + """Force render a Starlark app.""" + try: + # Guard: check if plugin_manager is initialized + if not api_v3.plugin_manager: + return jsonify({ + 'status': 'error', + 'message': 'Plugin manager not initialized', + 'pixlet_available': False + }), 500 + + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + + if not starlark_plugin: + return jsonify({ + 'status': 'error', + 'message': 'Starlark Apps plugin not installed' + }), 404 + + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({ + 'status': 'error', + 'message': f'App not found: {app_id}' + }), 404 + + # Force render + success = starlark_plugin._render_app(app, force=True) + + if success: + return jsonify({ + 'status': 'success', + 'message': 'App rendered successfully', + 'frame_count': len(app.frames) if app.frames else 0 + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Failed to render app' + }), 500 + + except Exception as e: + logger.error(f"Error rendering app {app_id}: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +def _get_tronbyte_repository_class(): + """ + Import TronbyteRepository from plugin-repos directory. + + Handles the hyphenated directory name by using importlib. + """ + import sys + import importlib.util + from pathlib import Path + + # Get path to the tronbyte_repository module + project_root = Path(__file__).parent.parent.parent + module_path = project_root / 'plugin-repos' / 'starlark-apps' / 'tronbyte_repository.py' + + if not module_path.exists(): + raise ImportError(f"TronbyteRepository module not found at {module_path}") + + # Load the module using importlib + spec = importlib.util.spec_from_file_location("tronbyte_repository", module_path) + module = importlib.util.module_from_spec(spec) + sys.modules["tronbyte_repository"] = module + spec.loader.exec_module(module) + + return module.TronbyteRepository + + +@api_v3.route('/starlark/repository/browse', methods=['GET']) +def browse_tronbyte_repository(): + """Browse apps in the Tronbyte repository.""" + try: + # Import repository module - doesn't require the plugin to be loaded + TronbyteRepository = _get_tronbyte_repository_class() + + # Get optional GitHub token from config + config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + github_token = config.get('github_token') + + repo = TronbyteRepository(github_token=github_token) + + # Get query parameters + search_query = request.args.get('search', '') + category = request.args.get('category', 'all') + limit = request.args.get('limit', 50, type=int) + if limit is None: + limit = 50 + # Clamp limit to reasonable range to prevent excessive API calls and memory usage + limit = max(1, min(limit, 200)) + + # Fetch apps with metadata + logger.info(f"Fetching Tronbyte apps (limit: {limit})") + apps = repo.list_apps_with_metadata(max_apps=limit) + + # Apply search filter + if search_query: + apps = repo.search_apps(search_query, apps) + + # Apply category filter + if category and category != 'all': + apps = repo.filter_by_category(category, apps) + + # Get rate limit info + rate_limit = repo.get_rate_limit_info() + + return jsonify({ + 'status': 'success', + 'apps': apps, + 'count': len(apps), + 'rate_limit': rate_limit, + 'filters': { + 'search': search_query, + 'category': category + } + }) + + except Exception as e: + logger.error(f"Error browsing repository: {e}", exc_info=True) + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@api_v3.route('/starlark/repository/install', methods=['POST']) +def install_from_tronbyte_repository(): + """Install an app directly from the Tronbyte repository.""" + try: + # Guard: check if plugin_manager is initialized + if not api_v3.plugin_manager: + return jsonify({ + 'status': 'error', + 'message': 'Plugin manager not initialized', + 'pixlet_available': False + }), 500 + + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + + if not starlark_plugin: + return jsonify({ + 'status': 'error', + 'message': 'Starlark Apps plugin not installed' + }), 404 + + data = request.get_json() + if not data or 'app_id' not in data: + return jsonify({ + 'status': 'error', + 'message': 'app_id is required' + }), 400 + + # Validate and sanitize app_id + app_id_input = data['app_id'] + app_id, app_id_error = _validate_and_sanitize_app_id(app_id_input) + if app_id_error: + return jsonify({ + 'status': 'error', + 'message': f'Invalid app_id: {app_id_error}' + }), 400 + + # Import repository module + TronbyteRepository = _get_tronbyte_repository_class() + import tempfile + + # Get optional GitHub token from config + config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + github_token = config.get('github_token') + + repo = TronbyteRepository(github_token=github_token) + + # Fetch app metadata + logger.info(f"Installing app from repository: {app_id}") + success, metadata, error = repo.get_app_metadata(app_id) + + if not success: + return jsonify({ + 'status': 'error', + 'message': f'Failed to fetch app metadata: {error}' + }), 404 + + # Download .star file to temporary location + with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp: + temp_path = tmp.name + + try: + success, error = repo.download_star_file(app_id, Path(temp_path)) + + if not success: + return jsonify({ + 'status': 'error', + 'message': f'Failed to download app: {error}' + }), 500 + + # Validate timing values + render_interval_input = data.get('render_interval', 300) + render_interval, render_error = _validate_timing_value( + render_interval_input, 'render_interval', min_val=1, max_val=86400 + ) + if render_error: + return jsonify({ + 'status': 'error', + 'message': render_error + }), 400 + if render_interval is None: + render_interval = 300 # Default + + display_duration_input = data.get('display_duration', 15) + display_duration, duration_error = _validate_timing_value( + display_duration_input, 'display_duration', min_val=1, max_val=86400 + ) + if duration_error: + return jsonify({ + 'status': 'error', + 'message': duration_error + }), 400 + if display_duration is None: + display_duration = 15 # Default + + # Install the app using plugin method + install_metadata = { + 'name': metadata.get('name', app_id), + 'render_interval': render_interval, + 'display_duration': display_duration + } + + success = starlark_plugin.install_app(app_id, temp_path, install_metadata) + + if success: + return jsonify({ + 'status': 'success', + 'message': f'App installed from repository: {metadata.get("name", app_id)}', + 'app_id': app_id, + 'metadata': metadata + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Failed to install app' + }), 500 + + finally: + # Clean up temp file + try: + os.unlink(temp_path) + except OSError as e: + logger.warning(f"Failed to clean up temp file {temp_path}: {e}") + + except Exception as e: + logger.error(f"Error installing from repository: {e}", exc_info=True) + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@api_v3.route('/starlark/repository/categories', methods=['GET']) +def get_tronbyte_categories(): + """Get list of available app categories.""" + try: + # Import repository module + TronbyteRepository = _get_tronbyte_repository_class() + + # Get optional GitHub token from config + config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + github_token = config.get('github_token') + + repo = TronbyteRepository(github_token=github_token) + + # Fetch all apps to extract unique categories + apps = repo.list_apps_with_metadata(max_apps=100) + + categories = set() + for app in apps: + category = app.get('category', '') + if category: + categories.add(category) + + return jsonify({ + 'status': 'success', + 'categories': sorted(list(categories)) + }) + + except Exception as e: + logger.error(f"Error fetching categories: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@api_v3.route('/starlark/install-pixlet', methods=['POST']) +def install_pixlet(): + """ + Download and install Pixlet binary. + + Runs the download_pixlet.sh script to fetch the appropriate binary + for the current platform. + + Returns: + JSON response with installation status + """ + try: + import subprocess + import os + from pathlib import Path + + # Get project root + project_root = Path(__file__).parent.parent.parent + script_path = project_root / 'scripts' / 'download_pixlet.sh' + + if not script_path.exists(): + return jsonify({ + 'status': 'error', + 'message': f'Installation script not found: {script_path}' + }), 404 + + # Make script executable + os.chmod(script_path, 0o755) + + # Run the download script + logger.info("Starting Pixlet download...") + result = subprocess.run( + [str(script_path)], + cwd=str(project_root), + capture_output=True, + text=True, + timeout=300 # 5 minute timeout + ) + + if result.returncode == 0: + logger.info("Pixlet downloaded successfully") + + # Try to reload the starlark-apps plugin now that Pixlet is available + reload_message = "" + try: + if api_v3.plugin_manager: + # Check if plugin is already loaded + existing_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + if existing_plugin: + # Plugin already loaded, just verify Pixlet works + reload_message = " Plugin already loaded." + else: + # Try to load/reload the plugin + plugin_info = api_v3.plugin_manager.get_plugin_info('starlark-apps') + if plugin_info: + # Plugin is registered, try to enable it + api_v3.plugin_manager.enable_plugin('starlark-apps') + reload_message = " Plugin enabled." + else: + reload_message = " Restart may be required to load plugin." + except Exception as reload_error: + logger.warning(f"Could not auto-reload plugin: {reload_error}") + reload_message = " Please refresh the page." + + return jsonify({ + 'status': 'success', + 'message': f'Pixlet installed successfully!{reload_message}', + 'output': result.stdout + }) + else: + logger.error(f"Pixlet download failed: {result.stderr}") + return jsonify({ + 'status': 'error', + 'message': f'Failed to download Pixlet: {result.stderr}', + 'output': result.stdout + }), 500 + + except subprocess.TimeoutExpired: + logger.error("Pixlet download timed out") + return jsonify({ + 'status': 'error', + 'message': 'Download timed out. Please check your internet connection and try again.' + }), 500 + + except Exception as e: + logger.error(f"Error installing Pixlet: {e}") + return jsonify({ + 'status': 'error', + 'message': f'Installation error: {str(e)}' + }), 500 \ No newline at end of file diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 43ce33247..4ea9b64b4 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -78,6 +78,8 @@ def load_partial(partial_name): return _load_cache_partial() elif partial_name == 'operation-history': return _load_operation_history_partial() + elif partial_name == 'starlark-apps': + return _load_starlark_apps_partial() else: return f"Partial '{partial_name}' not found", 404 @@ -306,16 +308,29 @@ def _load_operation_history_partial(): except Exception as e: return f"Error: {str(e)}", 500 +def _load_starlark_apps_partial(): + """Load Starlark apps management partial""" + try: + return render_template('v3/partials/starlark_apps.html') + except Exception as e: + return f"Error: {str(e)}", 500 + def _load_plugin_config_partial(plugin_id): """ Load plugin configuration partial - server-side rendered form. This replaces the client-side generateConfigForm() JavaScript. + + Special handling for starlark-apps plugin which has a custom interface. """ try: + # Special case: Starlark Apps plugin has its own full interface + if plugin_id == 'starlark-apps': + return _load_starlark_apps_partial() + if not pages_v3.plugin_manager: return '
Plugin manager not available
', 500 - + # Try to get plugin info first plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id) diff --git a/web_interface/static/v3/js/starlark_apps.js b/web_interface/static/v3/js/starlark_apps.js new file mode 100644 index 000000000..0d7ff8fa9 --- /dev/null +++ b/web_interface/static/v3/js/starlark_apps.js @@ -0,0 +1,1050 @@ +/** + * Starlark Apps Manager - Frontend JavaScript + * + * Handles UI interactions for managing Starlark (.star) apps + */ + +(function() { + 'use strict'; + + let currentConfigAppId = null; + let repositoryApps = []; + let repositoryCategories = []; + + // Track grids that already have event listeners to prevent duplicates + const gridsWithListeners = new WeakSet(); + const repoGridsWithListeners = new WeakSet(); + + // ======================================================================== + // Security: HTML Sanitization + // ======================================================================== + + /** + * Sanitize HTML string to prevent XSS attacks. + * Escapes HTML special characters. + */ + function sanitizeHtml(str) { + if (str === null || str === undefined) { + return ''; + } + const div = document.createElement('div'); + div.textContent = String(str); + return div.innerHTML; + } + + // Move modals to body to ensure they can overlay the entire page + function moveModalsToBody() { + const modalIds = ['upload-star-modal', 'starlark-config-modal', 'repository-browser-modal']; + + modalIds.forEach(modalId => { + const modal = document.getElementById(modalId); + if (modal && modal.parentElement !== document.body) { + document.body.appendChild(modal); + } + }); + } + + // Define init function first + function initStarlarkApps() { + try { + // Move modals to body so they can overlay the entire page + moveModalsToBody(); + + // Set up event listeners only once to prevent duplicates + if (!window.starlarkAppsInitialized) { + window.starlarkAppsInitialized = true; + setupEventListeners(); + setupRepositoryListeners(); + } + + // Always load data when init is called (handles tab switching) + loadStarlarkStatus(); + loadStarlarkApps(); + } catch (error) { + console.error('[Starlark] Error in initStarlarkApps:', error); + } + } + + // Expose init function globally BEFORE auto-init + window.initStarlarkApps = initStarlarkApps; + + // Initialize on page load - but DON'T auto-init when loaded dynamically + // Let the HTML partial's script handle initialization for HTMX swaps + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + initStarlarkApps(); + }); + } + // Note: Removed the else block - dynamic loading handled by HTML partial + + function setupEventListeners() { + // Upload button + const uploadBtn = document.getElementById('upload-star-btn'); + if (uploadBtn) { + uploadBtn.addEventListener('click', openUploadModal); + } + + // Refresh button + const refreshBtn = document.getElementById('refresh-starlark-apps-btn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', function() { + loadStarlarkApps(); + showNotification('Refreshing apps...', 'info'); + }); + } + + // Upload form + const uploadForm = document.getElementById('upload-star-form'); + if (uploadForm) { + uploadForm.addEventListener('submit', handleUploadSubmit); + } + + // File input and drop zone + const fileInput = document.getElementById('star-file-input'); + const dropZone = document.getElementById('upload-drop-zone'); + + if (fileInput && dropZone) { + dropZone.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', handleFileSelect); + + // Drag and drop + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('border-blue-500', 'bg-blue-50'); + }); + + dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('border-blue-500', 'bg-blue-50'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('border-blue-500', 'bg-blue-50'); + + const files = e.dataTransfer.files; + if (files.length > 0) { + // Create DataTransfer to properly assign files across browsers + const dataTransfer = new DataTransfer(); + for (let i = 0; i < files.length; i++) { + dataTransfer.items.add(files[i]); + } + fileInput.files = dataTransfer.files; + handleFileSelect({ target: fileInput }); + } + }); + } + + // Config form + const configForm = document.getElementById('starlark-config-form'); + if (configForm) { + configForm.addEventListener('submit', handleConfigSubmit); + } + } + + function handleFileSelect(event) { + const file = event.target.files[0]; + const fileNameDisplay = document.getElementById('selected-file-name'); + + if (file) { + fileNameDisplay.textContent = `Selected: ${file.name}`; + fileNameDisplay.classList.remove('hidden'); + + // Auto-fill app name from filename + const appNameInput = document.getElementById('star-app-name'); + if (appNameInput && !appNameInput.value) { + const baseName = file.name.replace('.star', '').replace(/[_-]/g, ' '); + appNameInput.value = baseName.charAt(0).toUpperCase() + baseName.slice(1); + } + } else { + fileNameDisplay.classList.add('hidden'); + } + } + + async function loadStarlarkStatus() { + try { + const response = await fetch('/api/v3/starlark/status'); + const data = await response.json(); + + const banner = document.getElementById('pixlet-status-banner'); + if (!banner) return; + + // Check if the plugin itself is not installed (different from Pixlet not being available) + if (data.status === 'error' && data.message && data.message.includes('plugin not installed')) { + banner.className = 'mb-6 p-4 rounded-lg border border-red-400 bg-red-50'; + banner.innerHTML = ` +
+ +
+

Starlark Apps Plugin Not Active

+

The Starlark Apps plugin needs to be discovered and enabled. This usually happens after a server restart.

+

Try refreshing the page or restarting the LEDMatrix service.

+
+
+ `; + } else if (data.status === 'error' || !data.pixlet_available) { + banner.className = 'mb-6 p-4 rounded-lg border border-yellow-400 bg-yellow-50'; + banner.innerHTML = ` +
+ +
+

Pixlet Not Available

+

Pixlet is required to render Starlark apps. Click below to download and install Pixlet automatically.

+ +
+
+ `; + + // Attach event listener to install button + const installBtn = document.getElementById('install-pixlet-btn'); + if (installBtn) { + installBtn.addEventListener('click', installPixlet); + } + } else { + // Get display info for magnification recommendation + const displayInfo = data.display_info || {}; + const magnifyRec = displayInfo.calculated_magnify || 1; + const displaySize = displayInfo.display_size || 'unknown'; + + // Sanitize all dynamic values + const safeVersion = sanitizeHtml(data.pixlet_version || 'Unknown'); + const safeInstalledApps = sanitizeHtml(data.installed_apps); + const safeEnabledApps = sanitizeHtml(data.enabled_apps); + const safeDisplaySize = sanitizeHtml(displaySize); + const safeMagnifyRec = sanitizeHtml(magnifyRec); + + let magnifyHint = ''; + if (magnifyRec > 1) { + magnifyHint = `
+ + Tip: Your ${safeDisplaySize} display works best with magnify=${safeMagnifyRec}. + Configure this in plugin settings for sharper output. +
`; + } + + banner.className = 'mb-6 p-4 rounded-lg border border-green-400 bg-green-50'; + banner.innerHTML = ` +
+
+
+ +
+

Pixlet Ready

+

Version: ${safeVersion} | ${safeInstalledApps} apps installed | ${safeEnabledApps} enabled

+
+
+ ${magnifyHint} +
+ ${data.plugin_enabled ? 'ENABLED' : 'DISABLED'} +
+ `; + } + } catch (error) { + console.error('Error loading Starlark status:', error); + } + } + + async function loadStarlarkApps() { + try { + const response = await fetch('/api/v3/starlark/apps'); + const data = await response.json(); + + if (data.status === 'error') { + showNotification(data.message, 'error'); + return; + } + + const grid = document.getElementById('starlark-apps-grid'); + const empty = document.getElementById('starlark-apps-empty'); + const count = document.getElementById('starlark-apps-count'); + + if (!grid) return; + + // Update count + if (count) { + count.textContent = `${data.count} app${data.count !== 1 ? 's' : ''}`; + } + + // Show empty state or apps grid + if (data.count === 0) { + grid.classList.add('hidden'); + if (empty) empty.classList.remove('hidden'); + return; + } + + if (empty) empty.classList.add('hidden'); + grid.classList.remove('hidden'); + + // Render apps + grid.innerHTML = data.apps.map(app => renderAppCard(app)).join(''); + + // Set up event delegation for app cards + setupAppCardEventDelegation(grid); + + } catch (error) { + console.error('Error loading Starlark apps:', error); + showNotification('Failed to load apps', 'error'); + } + } + + function renderAppCard(app) { + const statusColor = app.enabled ? 'green' : 'gray'; + const statusIcon = app.enabled ? 'check-circle' : 'pause-circle'; + const hasFrames = app.has_frames ? '' : ''; + + // Sanitize all dynamic values + const safeName = sanitizeHtml(app.name); + const safeId = sanitizeHtml(app.id); + const safeRenderInterval = sanitizeHtml(app.render_interval); + const safeDisplayDuration = sanitizeHtml(app.display_duration); + + return ` +
+
+
+

${safeName}

+

${safeId}

+
+
+ ${hasFrames} + +
+
+ +
+
Render: ${safeRenderInterval}s
+
Display: ${safeDisplayDuration}s
+ ${app.has_schema ? '
Configurable
' : ''} +
+ +
+ + + + +
+
+ `; + } + + /** + * Set up event delegation for app card buttons. + * Uses data attributes to avoid inline onclick handlers. + */ + function setupAppCardEventDelegation(grid) { + // Guard: only attach listener once per grid element + if (gridsWithListeners.has(grid)) { + return; + } + gridsWithListeners.add(grid); + + grid.addEventListener('click', async (e) => { + const button = e.target.closest('button[data-action]'); + if (!button) return; + + const card = button.closest('[data-app-id]'); + if (!card) return; + + const appId = card.dataset.appId; + const action = button.dataset.action; + + switch (action) { + case 'toggle': { + const enabled = button.dataset.enabled === 'true'; + await toggleStarlarkApp(appId, !enabled); + break; + } + case 'configure': + await configureStarlarkApp(appId); + break; + case 'render': + await renderStarlarkApp(appId); + break; + case 'uninstall': + await uninstallStarlarkApp(appId); + break; + } + }); + } + + function openUploadModal() { + const modal = document.getElementById('upload-star-modal'); + if (modal) { + // Force correct position + modal.style.top = '0'; + modal.style.left = '0'; + modal.style.right = '0'; + modal.style.bottom = '0'; + + // Prevent body scroll + document.body.style.overflow = 'hidden'; + + modal.style.display = 'flex'; + + // Reset form + const form = document.getElementById('upload-star-form'); + if (form) form.reset(); + const fileName = document.getElementById('selected-file-name'); + if (fileName) fileName.classList.add('hidden'); + + // Add ESC key handler + const handleEscape = (e) => { + if (e.key === 'Escape') { + window.closeUploadModal(); + } + }; + document.addEventListener('keydown', handleEscape); + modal._escapeHandler = handleEscape; + } + } + + window.closeUploadModal = function() { + const modal = document.getElementById('upload-star-modal'); + if (modal) { + modal.style.display = 'none'; + + // Restore body scroll + document.body.style.overflow = ''; + + // Remove ESC key handler + if (modal._escapeHandler) { + document.removeEventListener('keydown', modal._escapeHandler); + modal._escapeHandler = null; + } + } + }; + + async function handleUploadSubmit(event) { + event.preventDefault(); + + const submitBtn = document.getElementById('upload-star-submit-btn'); + const originalText = submitBtn.innerHTML; + + try { + submitBtn.disabled = true; + submitBtn.innerHTML = 'Uploading...'; + + const formData = new FormData(event.target); + + const response = await fetch('/api/v3/starlark/upload', { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (data.status === 'success') { + showNotification(data.message, 'success'); + window.closeUploadModal(); + loadStarlarkApps(); + loadStarlarkStatus(); + } else { + showNotification(data.message, 'error'); + } + + } catch (error) { + console.error('Error uploading app:', error); + showNotification('Failed to upload app', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.innerHTML = originalText; + } + } + + async function toggleStarlarkApp(appId, enabled) { + try { + const response = await fetch(`/api/v3/starlark/apps/${appId}/toggle`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }) + }); + + const data = await response.json(); + + if (data.status === 'success') { + showNotification(data.message, 'success'); + loadStarlarkApps(); + loadStarlarkStatus(); + } else { + showNotification(data.message, 'error'); + } + } catch (error) { + console.error('Error toggling app:', error); + showNotification('Failed to toggle app', 'error'); + } + } + + async function renderStarlarkApp(appId) { + try { + showNotification('Rendering app...', 'info'); + + const response = await fetch(`/api/v3/starlark/apps/${appId}/render`, { + method: 'POST' + }); + + const data = await response.json(); + + if (data.status === 'success') { + showNotification(data.message + ` (${data.frame_count} frames)`, 'success'); + loadStarlarkApps(); + } else { + showNotification(data.message, 'error'); + } + } catch (error) { + console.error('Error rendering app:', error); + showNotification('Failed to render app', 'error'); + } + } + + async function configureStarlarkApp(appId) { + try { + currentConfigAppId = appId; + + const response = await fetch(`/api/v3/starlark/apps/${appId}`); + const data = await response.json(); + + if (data.status === 'error') { + showNotification(data.message, 'error'); + return; + } + + const app = data.app; + + // Update modal title (textContent automatically escapes HTML) + document.getElementById('config-app-name').textContent = app.name || ''; + + // Generate config fields + const fieldsContainer = document.getElementById('starlark-config-fields'); + + if (!app.schema || Object.keys(app.schema).length === 0) { + fieldsContainer.innerHTML = ` +
+ +

This app has no configurable settings.

+
+ `; + } else { + fieldsContainer.innerHTML = generateConfigFields(app.schema, app.config); + } + + // Show modal + const configModal = document.getElementById('starlark-config-modal'); + if (configModal) { + // Force correct position + configModal.style.top = '0'; + configModal.style.left = '0'; + configModal.style.right = '0'; + configModal.style.bottom = '0'; + + // Prevent body scroll + document.body.style.overflow = 'hidden'; + + configModal.style.display = 'flex'; + + // Add ESC key handler + const handleEscape = (e) => { + if (e.key === 'Escape') { + window.closeConfigModal(); + } + }; + document.addEventListener('keydown', handleEscape); + configModal._escapeHandler = handleEscape; + } + + } catch (error) { + console.error('Error loading app config:', error); + showNotification('Failed to load configuration', 'error'); + } + } + + function generateConfigFields(schema, config) { + // Simple field generator - can be enhanced to handle complex Pixlet schemas + let html = ''; + + for (const [key, field] of Object.entries(schema)) { + const value = config[key] || field.default || ''; + const type = field.type || 'string'; + + // Sanitize all dynamic values + const safeKey = sanitizeHtml(key); + const safeName = sanitizeHtml(field.name || key); + const safeDescription = sanitizeHtml(field.description || ''); + const safeValue = sanitizeHtml(value); + const safePlaceholder = sanitizeHtml(field.placeholder || ''); + + html += ` +
+ + ${field.description ? `

${safeDescription}

` : ''} + `; + + if (type === 'bool' || type === 'boolean') { + html += ` + + `; + } else if (field.options) { + html += ` + '; + } else { + html += ` + + `; + } + + html += '
'; + } + + return html; + } + + window.closeConfigModal = function() { + const modal = document.getElementById('starlark-config-modal'); + if (modal) { + modal.style.display = 'none'; + + // Restore body scroll + document.body.style.overflow = ''; + + // Remove ESC key handler + if (modal._escapeHandler) { + document.removeEventListener('keydown', modal._escapeHandler); + modal._escapeHandler = null; + } + } + currentConfigAppId = null; + }; + + async function handleConfigSubmit(event) { + event.preventDefault(); + + if (!currentConfigAppId) return; + + const submitBtn = document.getElementById('save-starlark-config-btn'); + const originalText = submitBtn.innerHTML; + + try { + submitBtn.disabled = true; + submitBtn.innerHTML = 'Saving...'; + + const formData = new FormData(event.target); + const config = {}; + + for (const [key, value] of formData.entries()) { + // Handle checkboxes + const input = event.target.elements[key]; + if (input && input.type === 'checkbox') { + config[key] = input.checked; + } else { + config[key] = value; + } + } + + const response = await fetch(`/api/v3/starlark/apps/${currentConfigAppId}/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + + const data = await response.json(); + + if (data.status === 'success') { + showNotification(data.message, 'success'); + window.closeConfigModal(); + loadStarlarkApps(); + } else { + showNotification(data.message, 'error'); + } + + } catch (error) { + console.error('Error saving config:', error); + showNotification('Failed to save configuration', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.innerHTML = originalText; + } + } + + async function uninstallStarlarkApp(appId) { + if (!confirm(`Are you sure you want to uninstall this app? This cannot be undone.`)) { + return; + } + + try { + const response = await fetch(`/api/v3/starlark/apps/${appId}`, { + method: 'DELETE' + }); + + const data = await response.json(); + + if (data.status === 'success') { + showNotification(data.message, 'success'); + loadStarlarkApps(); + loadStarlarkStatus(); + } else { + showNotification(data.message, 'error'); + } + } catch (error) { + console.error('Error uninstalling app:', error); + showNotification('Failed to uninstall app', 'error'); + } + } + + // Utility function for notifications (assuming it exists in the main app) + function showNotification(message, type) { + if (typeof window.showNotification === 'function') { + window.showNotification(message, type); + } + } + + // ======================================================================== + // Repository Browser Functions + // ======================================================================== + + function setupRepositoryListeners() { + const browseBtn = document.getElementById('browse-repository-btn'); + if (browseBtn) { + browseBtn.addEventListener('click', openRepositoryBrowser); + } + + const applyFiltersBtn = document.getElementById('repo-apply-filters-btn'); + if (applyFiltersBtn) { + applyFiltersBtn.addEventListener('click', applyRepositoryFilters); + } + + const searchInput = document.getElementById('repo-search-input'); + if (searchInput) { + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + applyRepositoryFilters(); + } + }); + } + } + + function openRepositoryBrowser() { + const modal = document.getElementById('repository-browser-modal'); + if (!modal) return; + + // Force the modal to the correct position (fixes issue with parent transforms) + modal.style.top = '0'; + modal.style.left = '0'; + modal.style.right = '0'; + modal.style.bottom = '0'; + + // Prevent body scroll when modal is open + document.body.style.overflow = 'hidden'; + + modal.style.display = 'flex'; + + // Load categories first + loadRepositoryCategories(); + + // Then load apps + loadRepositoryApps(); + + // Add ESC key handler + const handleEscape = (e) => { + if (e.key === 'Escape') { + window.closeRepositoryBrowser(); + } + }; + document.addEventListener('keydown', handleEscape); + + // Store handler reference for cleanup + modal._escapeHandler = handleEscape; + } + + window.closeRepositoryBrowser = function() { + const modal = document.getElementById('repository-browser-modal'); + if (modal) { + modal.style.display = 'none'; + + // Restore body scroll + document.body.style.overflow = ''; + + // Remove ESC key handler + if (modal._escapeHandler) { + document.removeEventListener('keydown', modal._escapeHandler); + modal._escapeHandler = null; + } + } + }; + + async function loadRepositoryCategories() { + try { + const response = await fetch('/api/v3/starlark/repository/categories'); + const data = await response.json(); + + if (data.status === 'success') { + repositoryCategories = data.categories; + + const select = document.getElementById('repo-category-filter'); + if (select) { + // Keep "All Categories" option + select.innerHTML = ''; + + // Add category options + repositoryCategories.forEach(category => { + const option = document.createElement('option'); + option.value = category; + option.textContent = category.charAt(0).toUpperCase() + category.slice(1); + select.appendChild(option); + }); + } + } + } catch (error) { + console.error('Error loading categories:', error); + } + } + + async function loadRepositoryApps(search = '', category = 'all') { + const loading = document.getElementById('repo-apps-loading'); + const grid = document.getElementById('repo-apps-grid'); + const empty = document.getElementById('repo-apps-empty'); + + if (loading) loading.classList.remove('hidden'); + if (grid) grid.classList.add('hidden'); + if (empty) empty.classList.add('hidden'); + + try { + const params = new URLSearchParams({ limit: 100 }); + if (search) params.append('search', search); + if (category && category !== 'all') params.append('category', category); + + const url = `/api/v3/starlark/repository/browse?${params}`; + const response = await fetch(url); + const data = await response.json(); + + if (data.status === 'error') { + showNotification(data.message, 'error'); + if (loading) loading.classList.add('hidden'); + // Show error state in the modal + if (empty) { + empty.innerHTML = ` + +

Unable to Load Repository

+

${sanitizeHtml(data.message)}

+ `; + empty.classList.remove('hidden'); + } + return; + } + + repositoryApps = data.apps || []; + + // Update rate limit info + updateRateLimitInfo(data.rate_limit); + + // Hide loading + if (loading) loading.classList.add('hidden'); + + // Show apps or empty state + if (repositoryApps.length === 0) { + if (empty) empty.classList.remove('hidden'); + } else { + if (grid) { + const cardsHtml = repositoryApps.map(app => renderRepositoryAppCard(app)).join(''); + grid.innerHTML = cardsHtml; + grid.classList.remove('hidden'); + // Set up event delegation for repository app cards + setupRepositoryAppEventDelegation(grid); + } + } + + } catch (error) { + console.error('Error loading repository apps:', error); + showNotification('Failed to load repository apps', 'error'); + if (loading) loading.classList.add('hidden'); + } + } + + function renderRepositoryAppCard(app) { + const name = app.name || app.id.replace('_', ' ').replace('-', ' '); + const summary = app.summary || app.desc || 'No description available'; + const author = app.author || 'Community'; + const category = app.category || 'Other'; + + // Sanitize all dynamic values + const safeName = sanitizeHtml(name); + const safeId = sanitizeHtml(app.id); + const safeSummary = sanitizeHtml(summary); + const safeAuthor = sanitizeHtml(author); + const safeCategory = sanitizeHtml(category); + + return ` +
+
+

${safeName}

+

${safeSummary}

+
+ ${safeAuthor} + + ${safeCategory} +
+
+ + +
+ `; + } + + /** + * Set up event delegation for repository app install buttons. + * Uses data attributes to avoid inline onclick handlers. + */ + function setupRepositoryAppEventDelegation(grid) { + // Guard: only attach listener once per grid element + if (repoGridsWithListeners.has(grid)) { + return; + } + repoGridsWithListeners.add(grid); + + grid.addEventListener('click', async (e) => { + const button = e.target.closest('button[data-action="install"]'); + if (!button) return; + + const card = button.closest('[data-repo-app-id]'); + if (!card) return; + + const appId = card.dataset.repoAppId; + await installFromRepository(appId); + }); + } + + function updateRateLimitInfo(rateLimit) { + const info = document.getElementById('repo-rate-limit-info'); + if (!info || !rateLimit) return; + + const remaining = rateLimit.remaining || 0; + const limit = rateLimit.limit || 0; + const used = rateLimit.used || 0; + + // Sanitize numeric values + const safeRemaining = sanitizeHtml(remaining); + const safeLimit = sanitizeHtml(limit); + + let color = 'text-green-600'; + if (remaining < limit * 0.3) color = 'text-yellow-600'; + if (remaining < limit * 0.1) color = 'text-red-600'; + + info.innerHTML = ` + + GitHub API: ${safeRemaining}/${safeLimit} requests remaining + `; + } + + function applyRepositoryFilters() { + const search = document.getElementById('repo-search-input')?.value || ''; + const category = document.getElementById('repo-category-filter')?.value || 'all'; + + loadRepositoryApps(search, category); + } + + async function installFromRepository(appId) { + try { + showNotification(`Installing ${sanitizeHtml(appId)}...`, 'info'); + + const response = await fetch('/api/v3/starlark/repository/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app_id: appId }) + }); + + const data = await response.json(); + + if (data.status === 'success') { + showNotification(data.message, 'success'); + + // Close repository browser + window.closeRepositoryBrowser(); + + // Refresh installed apps + loadStarlarkApps(); + loadStarlarkStatus(); + } else { + showNotification(data.message, 'error'); + } + + } catch (error) { + console.error('Error installing from repository:', error); + showNotification('Failed to install app', 'error'); + } + } + + // ======================================================================== + // Pixlet Installation Function + // ======================================================================== + + async function installPixlet() { + const btn = document.getElementById('install-pixlet-btn'); + if (!btn) return; + + const originalText = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = 'Downloading Pixlet...'; + + try { + showNotification('Downloading Pixlet binary...', 'info'); + + const response = await fetch('/api/v3/starlark/install-pixlet', { + method: 'POST' + }); + + const data = await response.json(); + + if (data.status === 'success') { + showNotification(data.message, 'success'); + // Refresh status to show Pixlet is now available + setTimeout(() => loadStarlarkStatus(), 1000); + } else { + showNotification(data.message || 'Failed to install Pixlet', 'error'); + btn.disabled = false; + btn.innerHTML = originalText; + } + + } catch (error) { + console.error('Error installing Pixlet:', error); + showNotification('Failed to download Pixlet. Please check your internet connection.', 'error'); + btn.disabled = false; + btn.innerHTML = originalText; + } + } + +})(); diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 5bd0f1f4b..49fbd02df 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -2351,13 +2351,23 @@

this.updatePluginTabStates(); } }; + + // Use star icon for starlark apps, puzzle icon for regular plugins + const isStarlarkApp = plugin.id.startsWith('starlark:') || plugin.is_starlark_app; + const iconClass = isStarlarkApp ? 'fa-star text-yellow-500' : 'fa-puzzle-piece'; + tabButton.innerHTML = ` - ${this.escapeHtml(plugin.name || plugin.id)} + ${this.escapeHtml(plugin.name || plugin.id)} `; + // Store starlark app info for content loading + if (isStarlarkApp) { + tabButton.setAttribute('data-starlark-app-id', plugin.starlark_app_id || plugin.id.replace('starlark:', '')); + } + // Insert before the closing tag pluginTabsNav.appendChild(tabButton); - console.log('[FULL] Added tab for plugin:', plugin.id); + console.log('[FULL] Added tab for plugin:', plugin.id, isStarlarkApp ? '(starlark app)' : ''); }); console.log('[FULL] Plugin tabs update completed. Total tabs:', pluginTabsNav.querySelectorAll('.plugin-tab').length); @@ -3533,14 +3543,40 @@

console.error('loadPluginConfig requires component context'); return; } - + console.log('Loading config for plugin:', pluginId); componentContext.loading = true; try { + // Handle starlark apps differently + if (pluginId.startsWith('starlark:')) { + const starlarkAppId = pluginId.replace('starlark:', ''); + console.log('Loading starlark app config:', starlarkAppId); + + const appResponse = await fetch(`/api/v3/starlark/apps/${starlarkAppId}`).then(r => r.json()); + + if (appResponse.status === 'success' && appResponse.app) { + componentContext.config = appResponse.app.config || {}; + componentContext.config.enabled = appResponse.app.enabled; + componentContext.schema = appResponse.app.schema || {}; + componentContext.webUiActions = []; + componentContext.isStarlarkApp = true; + componentContext.starlarkAppId = starlarkAppId; + componentContext.starlarkAppInfo = appResponse.app; + } else { + console.error('Failed to load starlark app:', appResponse.message); + componentContext.config = { enabled: true }; + componentContext.schema = {}; + componentContext.webUiActions = []; + } + + componentContext.loading = false; + return; + } + // Load config, schema, and installed plugins (for web_ui_actions) in parallel let configData, schemaData, pluginsData; - + if (window.PluginAPI && window.PluginAPI.batch) { try { const results = await window.PluginAPI.batch([ diff --git a/web_interface/templates/v3/partials/starlark_apps.html b/web_interface/templates/v3/partials/starlark_apps.html new file mode 100644 index 000000000..e55c2b9e7 --- /dev/null +++ b/web_interface/templates/v3/partials/starlark_apps.html @@ -0,0 +1,246 @@ +
+
+

Starlark Apps

+

Manage Starlark widgets from the Tronbyte/Tidbyt community. Run apps without modification.

+
+ + +
+ +
+ + +
+
+ + + +
+
+ + +
+
+
+

Installed Apps

+ 0 apps +
+
+ + +
+ +
+ + + +
+
+ + + + + + + + + + + +