diff --git a/.gitignore b/.gitignore index 3df5d703..99921d3a 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ plugins/* # Binary files and backups bin/pixlet/ config/backups/ + +# Starlark apps runtime storage (installed .star files and cached renders) +/starlark-apps/ diff --git a/plugin-repos/starlark-apps/__init__.py b/plugin-repos/starlark-apps/__init__.py new file mode 100644 index 00000000..1d5dabca --- /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 00000000..e493204f --- /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 00000000..bb52d9e1 --- /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 00000000..e5102478 --- /dev/null +++ b/plugin-repos/starlark-apps/manager.py @@ -0,0 +1,849 @@ +""" +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 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, VegasDisplayMode +from src.logging_config import get_logger +from .pixlet_renderer import PixletRenderer +from .frame_extractor import FrameExtractor + +logger = get_logger(__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) + + # ─── Vegas Mode Integration ────────────────────────────────────── + + def get_vegas_content(self) -> Optional[List[Image.Image]]: + """Return rendered frames from enabled starlark apps for vegas scroll.""" + images = [] + for app in self.apps.values(): + if not app.is_enabled(): + continue + # Use cached frames if available + if app.frames: + images.extend([frame for frame, delay in app.frames]) + else: + # Try to render and extract frames + if self._render_app(app): + if app.frames: + images.extend([frame for frame, delay in app.frames]) + return images if images else None + + def get_vegas_content_type(self) -> str: + """Indicate the type of content for Vegas scroll.""" + return "multi" + + def get_vegas_display_mode(self) -> VegasDisplayMode: + """Get the display mode for Vegas scroll integration.""" + return VegasDisplayMode.FIXED_SEGMENT + + 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 00000000..ef44707b --- /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 00000000..29805331 --- /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/requirements.txt b/plugin-repos/starlark-apps/requirements.txt new file mode 100644 index 00000000..8c3fe577 --- /dev/null +++ b/plugin-repos/starlark-apps/requirements.txt @@ -0,0 +1,3 @@ +Pillow>=10.0.0 +PyYAML>=6.0 +requests>=2.31.0 diff --git a/plugin-repos/starlark-apps/tronbyte_repository.py b/plugin-repos/starlark-apps/tronbyte_repository.py new file mode 100644 index 00000000..7795a15a --- /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 00000000..b3d4070a --- /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/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index adfa3143..b95b2dba 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -1852,6 +1852,31 @@ def get_installed_plugins(): 'vegas_content_type': vegas_content_type }) + # Append virtual entries for installed Starlark apps + 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(): + plugins.append({ + 'id': f'starlark:{app_id}', + 'name': app.manifest.get('name', app_id), + 'version': 'starlark', + 'author': app.manifest.get('author', 'Tronbyte Community'), + 'category': 'Starlark App', + 'description': app.manifest.get('summary', 'Starlark app'), + 'tags': ['starlark'], + 'enabled': app.is_enabled(), + 'verified': False, + 'loaded': True, + 'last_updated': None, + 'last_commit': None, + 'last_commit_message': None, + 'branch': None, + 'web_ui_actions': [], + 'vegas_mode': 'fixed', + 'vegas_content_type': 'multi', + 'is_starlark_app': True, + }) + return jsonify({'status': 'success', 'data': {'plugins': plugins}}) except Exception as e: import traceback @@ -2127,6 +2152,20 @@ def toggle_plugin(): current_enabled = config.get(plugin_id, {}).get('enabled', False) enabled = not current_enabled + # Handle starlark app toggle (starlark: prefix) + if plugin_id.startswith('starlark:'): + starlark_app_id = plugin_id[len('starlark:'):] + starlark_plugin = api_v3.plugin_manager.get_plugin('starlark-apps') + if not starlark_plugin or starlark_app_id not in starlark_plugin.apps: + return jsonify({'status': 'error', 'message': f'Starlark app not found: {starlark_app_id}'}), 404 + app = starlark_plugin.apps[starlark_app_id] + app.manifest['enabled'] = enabled + 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 @@ -6903,4 +6942,525 @@ def clear_old_errors(): message="Failed to clear old errors", details=str(e), status_code=500 - ) \ No newline at end of file + ) + + +# ─── Starlark Apps API ────────────────────────────────────────────────────── + +def _get_tronbyte_repository_class(): + """Import TronbyteRepository from plugin-repos directory.""" + import importlib.util + + 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}") + + 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 + + +def _validate_and_sanitize_app_id(app_id, fallback_source=None): + """Validate and sanitize app_id to a safe slug.""" + if not app_id and fallback_source: + app_id = fallback_source + if not app_id: + return None, "app_id is required" + if '..' in app_id or '/' in app_id or '\\' in app_id: + return None, "app_id contains invalid characters" + + sanitized = re.sub(r'[^a-z0-9_]', '_', app_id.lower()).strip('_') + if not sanitized: + sanitized = f"app_{hashlib.sha256(app_id.encode()).hexdigest()[:12]}" + if sanitized[0].isdigit(): + sanitized = f"app_{sanitized}" + return sanitized, None + + +def _validate_timing_value(value, field_name, min_val=1, max_val=86400): + """Validate and coerce timing values.""" + if value is None: + return None, None + try: + int_value = int(value) + except (ValueError, TypeError): + return None, f"{field_name} must be an integer" + 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 + + +def _get_starlark_plugin(): + """Get the starlark-apps plugin instance, or None.""" + if not api_v3.plugin_manager: + return None + return api_v3.plugin_manager.get_plugin('starlark-apps') + + +@api_v3.route('/starlark/status', methods=['GET']) +def get_starlark_status(): + """Get Starlark plugin status and Pixlet availability.""" + try: + if not api_v3.plugin_manager: + return jsonify({'status': 'error', 'message': 'Plugin manager not initialized', 'pixlet_available': False}), 500 + + starlark_plugin = _get_starlark_plugin() + if starlark_plugin: + 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 Pixlet availability directly + import shutil + import platform + + system = platform.system().lower() + machine = platform.machine().lower() + bin_dir = PROJECT_ROOT / 'bin' / 'pixlet' + + 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": + pixlet_binary = bin_dir / ("pixlet-darwin-arm64" if "arm64" in machine else "pixlet-darwin-amd64") + + pixlet_available = (pixlet_binary and pixlet_binary.exists()) or shutil.which('pixlet') is not None + + return jsonify({ + 'status': 'success', + 'pixlet_available': pixlet_available, + 'pixlet_version': None, + 'installed_apps': 0, + 'enabled_apps': 0, + 'plugin_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: + starlark_plugin = _get_starlark_plugin() + if not starlark_plugin: + return jsonify({'status': 'success', 'apps': [], 'count': 0, 'message': 'Plugin not loaded'}) + + apps_list = [] + for app_id, app_instance in starlark_plugin.apps.items(): + apps_list.append({ + 'id': app_id, + 'name': app_instance.manifest.get('name', app_id), + 'enabled': app_instance.is_enabled(), + 'has_frames': app_instance.frames is not None, + '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: + starlark_plugin = _get_starlark_plugin() + if not starlark_plugin: + return jsonify({'status': 'error', 'message': 'Starlark Apps plugin not loaded'}), 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, + } + }) + + except Exception as e: + logger.error(f"Error getting starlark app {app_id}: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@api_v3.route('/starlark/upload', methods=['POST']) +def upload_starlark_app(): + """Upload and install a new Starlark app.""" + try: + starlark_plugin = _get_starlark_plugin() + if not starlark_plugin: + return jsonify({'status': 'error', 'message': 'Starlark Apps plugin not loaded'}), 404 + + if 'file' not in request.files: + return jsonify({'status': 'error', 'message': 'No file uploaded'}), 400 + + file = request.files['file'] + if not file.filename or not file.filename.endswith('.star'): + return jsonify({'status': 'error', 'message': 'File must have .star extension'}), 400 + + app_name = request.form.get('name') + app_id_input = request.form.get('app_id') + 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 + + render_interval_input = request.form.get('render_interval') + render_interval = 300 + if render_interval_input is not None: + render_interval, err = _validate_timing_value(render_interval_input, 'render_interval') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + render_interval = render_interval or 300 + + display_duration_input = request.form.get('display_duration') + display_duration = 15 + if display_duration_input is not None: + display_duration, err = _validate_timing_value(display_duration_input, 'display_duration') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + display_duration = display_duration or 15 + + import tempfile + with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp: + file.save(tmp.name) + temp_path = tmp.name + + try: + 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: {app_id}', 'app_id': app_id}) + else: + return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500 + finally: + try: + os.unlink(temp_path) + except OSError: + pass + + 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: + starlark_plugin = _get_starlark_plugin() + if not starlark_plugin: + return jsonify({'status': 'error', 'message': 'Starlark Apps plugin not loaded'}), 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 = _get_starlark_plugin() + if not starlark_plugin: + return jsonify({'status': 'error', 'message': 'Plugin not loaded'}), 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: + starlark_plugin = _get_starlark_plugin() + if not starlark_plugin: + return jsonify({'status': 'error', 'message': 'Plugin not loaded'}), 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 + + if 'render_interval' in data: + val, err = _validate_timing_value(data['render_interval'], 'render_interval') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + data['render_interval'] = val + + if 'display_duration' in data: + val, err = _validate_timing_value(data['display_duration'], 'display_duration') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + data['display_duration'] = val + + app.config.update(data) + if app.save_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: + starlark_plugin = _get_starlark_plugin() + if not starlark_plugin: + return jsonify({'status': 'error', 'message': 'Plugin not loaded'}), 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: + enabled = not app.is_enabled() + + app.manifest['enabled'] = enabled + + 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: + starlark_plugin = _get_starlark_plugin() + if not starlark_plugin: + return jsonify({'status': 'error', 'message': 'Plugin not loaded'}), 404 + + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + + success = starlark_plugin._render_app(app, force=True) + if success: + return jsonify({'status': 'success', 'message': 'App rendered', '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 + + +@api_v3.route('/starlark/repository/browse', methods=['GET']) +def browse_tronbyte_repository(): + """Browse apps in the Tronbyte repository.""" + try: + TronbyteRepository = _get_tronbyte_repository_class() + + 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) + + search_query = request.args.get('search', '') + category = request.args.get('category', 'all') + limit = max(1, min(request.args.get('limit', 50, type=int), 200)) + + apps = repo.list_apps_with_metadata(max_apps=limit) + if search_query: + apps = repo.search_apps(search_query, apps) + if category and category != 'all': + apps = repo.filter_by_category(category, apps) + + 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}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@api_v3.route('/starlark/repository/install', methods=['POST']) +def install_from_tronbyte_repository(): + """Install an app from the Tronbyte repository.""" + try: + starlark_plugin = _get_starlark_plugin() + if not starlark_plugin: + return jsonify({'status': 'error', 'message': 'Plugin not loaded'}), 404 + + data = request.get_json() + if not data or 'app_id' not in data: + return jsonify({'status': 'error', 'message': 'app_id is required'}), 400 + + app_id, app_id_error = _validate_and_sanitize_app_id(data['app_id']) + if app_id_error: + return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400 + + TronbyteRepository = _get_tronbyte_repository_class() + import tempfile + + 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) + + success, metadata, error = repo.get_app_metadata(data['app_id']) + if not success: + return jsonify({'status': 'error', 'message': f'Failed to fetch app metadata: {error}'}), 404 + + with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp: + temp_path = tmp.name + + try: + success, error = repo.download_star_file(data['app_id'], Path(temp_path)) + if not success: + return jsonify({'status': 'error', 'message': f'Failed to download app: {error}'}), 500 + + render_interval = data.get('render_interval', 300) + ri, err = _validate_timing_value(render_interval, 'render_interval') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + render_interval = ri or 300 + + display_duration = data.get('display_duration', 15) + dd, err = _validate_timing_value(display_duration, 'display_duration') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + display_duration = dd or 15 + + install_metadata = { + 'name': metadata.get('name', app_id) if metadata else app_id, + 'render_interval': render_interval, + 'display_duration': display_duration + } + + success = starlark_plugin.install_app(data['app_id'], temp_path, install_metadata) + if success: + return jsonify({'status': 'success', 'message': f'App installed: {metadata.get("name", app_id) if metadata else app_id}', 'app_id': app_id}) + else: + return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500 + finally: + try: + os.unlink(temp_path) + except OSError: + pass + + except Exception as e: + logger.error(f"Error installing from repository: {e}") + 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: + TronbyteRepository = _get_tronbyte_repository_class() + config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + repo = TronbyteRepository(github_token=config.get('github_token')) + + apps = repo.list_apps_with_metadata(max_apps=100) + categories = sorted({app.get('category', '') for app in apps if app.get('category')}) + + return jsonify({'status': 'success', 'categories': 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.""" + try: + script_path = PROJECT_ROOT / 'scripts' / 'download_pixlet.sh' + if not script_path.exists(): + return jsonify({'status': 'error', 'message': 'Installation script not found'}), 404 + + os.chmod(script_path, 0o755) + + result = subprocess.run( + [str(script_path)], + cwd=str(PROJECT_ROOT), + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode == 0: + logger.info("Pixlet downloaded successfully") + return jsonify({'status': 'success', 'message': 'Pixlet installed successfully!', 'output': result.stdout}) + else: + return jsonify({'status': 'error', 'message': f'Failed to download Pixlet: {result.stderr}'}), 500 + + except subprocess.TimeoutExpired: + return jsonify({'status': 'error', 'message': 'Download timed out'}), 500 + except Exception as e: + logger.error(f"Error installing Pixlet: {e}") + return jsonify({'status': 'error', 'message': 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 34fce5d2..5eafcf03 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -322,7 +322,11 @@ def _load_plugin_config_partial(plugin_id): try: if not pages_v3.plugin_manager: return '
Plugin manager not available
', 500 - + + # Handle starlark app config (starlark:) + if plugin_id.startswith('starlark:'): + return _load_starlark_config_partial(plugin_id[len('starlark:'):]) + # Try to get plugin info first plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id) @@ -429,3 +433,34 @@ def _load_plugin_config_partial(plugin_id): import traceback traceback.print_exc() return f'
Error loading plugin config: {str(e)}
', 500 + + +def _load_starlark_config_partial(app_id): + """Load configuration partial for a Starlark app.""" + try: + starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') + if not starlark_plugin: + return '
Starlark Apps plugin not loaded
', 404 + + app = starlark_plugin.apps.get(app_id) + if not app: + return f'
Starlark app not found: {app_id}
', 404 + + return render_template( + 'v3/partials/starlark_config.html', + app_id=app_id, + app_name=app.manifest.get('name', app_id), + app_enabled=app.is_enabled(), + render_interval=app.get_render_interval(), + display_duration=app.get_display_duration(), + config=app.config, + schema=app.schema, + has_frames=app.frames is not None, + frame_count=len(app.frames) if app.frames else 0, + last_render_time=app.last_render_time, + ) + + except Exception as e: + import traceback + traceback.print_exc() + return f'
Error loading starlark config: {str(e)}
', 500 diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index c5233fed..60e7a634 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -1399,6 +1399,7 @@ function renderInstalledPlugins(plugins) {

${escapeHtml(plugin.name || plugin.id)}

+ ${plugin.is_starlark_app ? 'Starlark' : ''} ${plugin.verified ? 'Verified' : ''}
@@ -1610,18 +1611,37 @@ function handlePluginAction(event) { }); break; case 'uninstall': - waitForFunction('uninstallPlugin', 10, 50) - .then(uninstallFunc => { - uninstallFunc(pluginId); - }) - .catch(error => { - console.error('[EVENT DELEGATION]', error.message); - if (typeof showNotification === 'function') { - showNotification('Uninstall function not loaded. Please refresh the page.', 'error'); - } else { - alert('Uninstall function not loaded. Please refresh the page.'); - } - }); + if (pluginId.startsWith('starlark:')) { + // Starlark app uninstall uses dedicated endpoint + const starlarkAppId = pluginId.slice('starlark:'.length); + if (!confirm(`Uninstall Starlark app "${starlarkAppId}"?`)) break; + fetch(`/api/v3/starlark/apps/${encodeURIComponent(starlarkAppId)}`, {method: 'DELETE'}) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + if (typeof showNotification === 'function') showNotification('Starlark app uninstalled', 'success'); + else alert('Starlark app uninstalled'); + if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); + else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins(); + } else { + alert('Uninstall failed: ' + (data.message || 'Unknown error')); + } + }) + .catch(err => alert('Uninstall failed: ' + err.message)); + } else { + waitForFunction('uninstallPlugin', 10, 50) + .then(uninstallFunc => { + uninstallFunc(pluginId); + }) + .catch(error => { + console.error('[EVENT DELEGATION]', error.message); + if (typeof showNotification === 'function') { + showNotification('Uninstall function not loaded. Please refresh the page.', 'error'); + } else { + alert('Uninstall function not loaded. Please refresh the page.'); + } + }); + } break; } } @@ -7297,3 +7317,246 @@ setTimeout(function() { }, 500); }, 200); +// ─── Starlark Apps Integration ────────────────────────────────────────────── + +(function() { + 'use strict'; + + let starlarkSectionVisible = false; + let starlarkAppsCache = null; + + function initStarlarkSection() { + const toggleBtn = document.getElementById('toggle-starlark-section'); + if (toggleBtn && !toggleBtn._starlarkInit) { + toggleBtn._starlarkInit = true; + toggleBtn.addEventListener('click', function() { + starlarkSectionVisible = !starlarkSectionVisible; + const content = document.getElementById('starlark-section-content'); + const icon = document.getElementById('starlark-section-icon'); + if (content) content.classList.toggle('hidden', !starlarkSectionVisible); + if (icon) { + icon.classList.toggle('fa-chevron-down', !starlarkSectionVisible); + icon.classList.toggle('fa-chevron-up', starlarkSectionVisible); + } + this.querySelector('span').textContent = starlarkSectionVisible ? 'Hide' : 'Show'; + if (starlarkSectionVisible) { + loadStarlarkStatus(); + loadStarlarkCategories(); + } + }); + } + + const browseBtn = document.getElementById('starlark-browse-btn'); + if (browseBtn && !browseBtn._starlarkInit) { + browseBtn._starlarkInit = true; + browseBtn.addEventListener('click', browseStarlarkApps); + } + + const uploadBtn = document.getElementById('starlark-upload-btn'); + if (uploadBtn && !uploadBtn._starlarkInit) { + uploadBtn._starlarkInit = true; + uploadBtn.addEventListener('click', function() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.star'; + input.onchange = function(e) { + if (e.target.files.length > 0) uploadStarlarkFile(e.target.files[0]); + }; + input.click(); + }); + } + } + + function loadStarlarkStatus() { + fetch('/api/v3/starlark/status') + .then(r => r.json()) + .then(data => { + const banner = document.getElementById('starlark-pixlet-status'); + if (!banner) return; + if (data.pixlet_available) { + banner.innerHTML = `
+ Pixlet available${data.pixlet_version ? ' (' + escapeHtml(data.pixlet_version) + ')' : ''} — ${data.installed_apps || 0} app(s) installed +
`; + } else { + banner.innerHTML = `
+ Pixlet not installed. + +
`; + } + }) + .catch(err => console.error('Starlark status error:', err)); + } + + function loadStarlarkCategories() { + fetch('/api/v3/starlark/repository/categories') + .then(r => r.json()) + .then(data => { + if (data.status !== 'success') return; + const select = document.getElementById('starlark-category'); + if (!select) return; + select.innerHTML = ''; + (data.categories || []).forEach(cat => { + const opt = document.createElement('option'); + opt.value = cat; + opt.textContent = cat; + select.appendChild(opt); + }); + }) + .catch(err => console.error('Starlark categories error:', err)); + } + + function browseStarlarkApps() { + const search = (document.getElementById('starlark-search') || {}).value || ''; + const category = (document.getElementById('starlark-category') || {}).value || ''; + const grid = document.getElementById('starlark-apps-grid'); + const countEl = document.getElementById('starlark-apps-count'); + + if (grid) grid.innerHTML = '
Loading Tronbyte apps...
'; + + const params = new URLSearchParams(); + if (search) params.set('search', search); + if (category) params.set('category', category); + params.set('limit', '50'); + + fetch('/api/v3/starlark/repository/browse?' + params.toString()) + .then(r => r.json()) + .then(data => { + if (data.status !== 'success') { + if (grid) grid.innerHTML = `
${escapeHtml(data.message || 'Failed to load')}
`; + return; + } + starlarkAppsCache = data.apps; + if (countEl) countEl.textContent = `${data.count} apps`; + renderStarlarkApps(data.apps, grid); + + if (data.rate_limit) { + const rl = data.rate_limit; + console.log(`[Starlark] GitHub rate limit: ${rl.remaining}/${rl.limit} remaining`); + } + }) + .catch(err => { + console.error('Starlark browse error:', err); + if (grid) grid.innerHTML = '
Error loading apps
'; + }); + } + + function renderStarlarkApps(apps, grid) { + if (!grid) return; + if (!apps || apps.length === 0) { + grid.innerHTML = '

No Starlark apps found

'; + return; + } + + grid.innerHTML = apps.map(app => ` +
+
+
+
+

${escapeHtml(app.name || app.id)}

+ Starlark +
+
+ ${app.author ? `

${escapeHtml(app.author)}

` : ''} + ${app.category ? `

${escapeHtml(app.category)}

` : ''} +
+

${escapeHtml(app.summary || app.desc || 'No description')}

+
+
+
+ + +
+
+ `).join(''); + } + + function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + window.installStarlarkApp = function(appId) { + if (!confirm(`Install Starlark app "${appId}" from Tronbyte repository?`)) return; + + fetch('/api/v3/starlark/repository/install', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({app_id: appId}) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + alert(`Installed: ${data.message || appId}`); + // Refresh installed plugins to show the new starlark app + if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); + else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins(); + } else { + alert(`Install failed: ${data.message || 'Unknown error'}`); + } + }) + .catch(err => { + console.error('Install error:', err); + alert('Install failed: ' + err.message); + }); + }; + + window.installPixlet = function() { + if (!confirm('Download and install Pixlet binary? This may take a few minutes.')) return; + + fetch('/api/v3/starlark/install-pixlet', {method: 'POST'}) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + alert(data.message || 'Pixlet installed!'); + loadStarlarkStatus(); + } else { + alert('Pixlet install failed: ' + (data.message || 'Unknown error')); + } + }) + .catch(err => alert('Pixlet install failed: ' + err.message)); + }; + + function uploadStarlarkFile(file) { + const formData = new FormData(); + formData.append('file', file); + + const appId = file.name.replace('.star', ''); + formData.append('app_id', appId); + formData.append('name', appId.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())); + + fetch('/api/v3/starlark/upload', {method: 'POST', body: formData}) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + alert(`Uploaded: ${data.app_id}`); + if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); + else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins(); + } else { + alert('Upload failed: ' + (data.message || 'Unknown error')); + } + }) + .catch(err => alert('Upload failed: ' + err.message)); + } + + // Initialize when plugins tab loads + const origInit = window.initializePlugins; + window.initializePlugins = function() { + if (origInit) origInit(); + initStarlarkSection(); + }; + + // Also try to init on DOMContentLoaded and on HTMX load + document.addEventListener('DOMContentLoaded', initStarlarkSection); + document.addEventListener('htmx:afterSwap', function(e) { + if (e.detail && e.detail.target && e.detail.target.id === 'plugins-content') { + initStarlarkSection(); + } + }); +})(); + diff --git a/web_interface/templates/v3/partials/plugins.html b/web_interface/templates/v3/partials/plugins.html index 024716ca..ef155ce8 100644 --- a/web_interface/templates/v3/partials/plugins.html +++ b/web_interface/templates/v3/partials/plugins.html @@ -179,6 +179,48 @@
+ +
+
+
+

Starlark Apps

+ +
+ +
+ +
+
diff --git a/web_interface/templates/v3/partials/starlark_config.html b/web_interface/templates/v3/partials/starlark_config.html new file mode 100644 index 00000000..353237cc --- /dev/null +++ b/web_interface/templates/v3/partials/starlark_config.html @@ -0,0 +1,160 @@ +
+ +
+
+

+ {{ app_name }} +

+

Starlark App — ID: {{ app_id }}

+
+
+ Starlark + {% if app_enabled %} + Enabled + {% else %} + Disabled + {% endif %} +
+
+ + +
+

Status

+
+
+ Frames: + {{ frame_count if has_frames else 'Not rendered' }} +
+
+ Render Interval: + {{ render_interval }}s +
+
+ Display Duration: + {{ display_duration }}s +
+
+ Last Render: + {{ last_render_time }} +
+
+
+ + +
+ + +
+ + + {% if schema %} +
+

App Configuration

+
+ {% for key, value in config.items() %} +
+ + +
+ {% endfor %} + +
+
+ {% elif config %} +
+

App Configuration

+
+ {% for key, value in config.items() %} +
+ + +
+ {% endfor %} + +
+
+ {% else %} +
+ This app has no configurable settings. +
+ {% endif %} +
+ +