mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
feat: integrate Starlark/Tronbyte app support into plugin system
Add starlark-apps plugin that renders Tidbyt/Tronbyte .star apps via Pixlet binary and integrates them into the existing Plugin Manager UI as virtual plugins. Includes vegas scroll support, Tronbyte repository browsing, and per-app configuration. - Extract working starlark plugin code from starlark branch onto fresh main - Fix plugin conventions (get_logger, VegasDisplayMode, BasePlugin) - Add 13 starlark API endpoints to api_v3.py (CRUD, browse, install, render) - Virtual plugin entries (starlark:<app_id>) in installed plugins list - Starlark-aware toggle and config routing in pages_v3.py - Tronbyte repository browser section in Plugin Store UI - Pixlet binary download script (scripts/download_pixlet.sh) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
7
plugin-repos/starlark-apps/__init__.py
Normal file
7
plugin-repos/starlark-apps/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Starlark Apps Plugin Package
|
||||
|
||||
Seamlessly import and manage Starlark (.star) widgets from the Tronbyte/Tidbyt community.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
100
plugin-repos/starlark-apps/config_schema.json
Normal file
100
plugin-repos/starlark-apps/config_schema.json
Normal file
@@ -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
|
||||
}
|
||||
285
plugin-repos/starlark-apps/frame_extractor.py
Normal file
285
plugin-repos/starlark-apps/frame_extractor.py
Normal file
@@ -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
|
||||
849
plugin-repos/starlark-apps/manager.py
Normal file
849
plugin-repos/starlark-apps/manager.py
Normal file
@@ -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
|
||||
26
plugin-repos/starlark-apps/manifest.json
Normal file
26
plugin-repos/starlark-apps/manifest.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
346
plugin-repos/starlark-apps/pixlet_renderer.py
Normal file
346
plugin-repos/starlark-apps/pixlet_renderer.py
Normal file
@@ -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"
|
||||
3
plugin-repos/starlark-apps/requirements.txt
Normal file
3
plugin-repos/starlark-apps/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Pillow>=10.0.0
|
||||
PyYAML>=6.0
|
||||
requests>=2.31.0
|
||||
366
plugin-repos/starlark-apps/tronbyte_repository.py
Normal file
366
plugin-repos/starlark-apps/tronbyte_repository.py
Normal file
@@ -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
|
||||
}
|
||||
139
scripts/download_pixlet.sh
Executable file
139
scripts/download_pixlet.sh
Executable file
@@ -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
|
||||
@@ -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:<app_id> 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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ─── 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/<app_id>', 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/<app_id>', 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/<app_id>/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/<app_id>/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/<app_id>/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/<app_id>/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
|
||||
@@ -322,7 +322,11 @@ def _load_plugin_config_partial(plugin_id):
|
||||
try:
|
||||
if not pages_v3.plugin_manager:
|
||||
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
|
||||
|
||||
|
||||
# Handle starlark app config (starlark:<app_id>)
|
||||
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'<div class="text-red-500 p-4">Error loading plugin config: {str(e)}</div>', 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 '<div class="text-yellow-600 p-4"><i class="fas fa-exclamation-triangle mr-2"></i>Starlark Apps plugin not loaded</div>', 404
|
||||
|
||||
app = starlark_plugin.apps.get(app_id)
|
||||
if not app:
|
||||
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 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'<div class="text-red-500 p-4">Error loading starlark config: {str(e)}</div>', 500
|
||||
|
||||
@@ -1399,6 +1399,7 @@ function renderInstalledPlugins(plugins) {
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center flex-wrap gap-2 mb-2">
|
||||
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(plugin.name || plugin.id)}</h4>
|
||||
${plugin.is_starlark_app ? '<span class="badge badge-warning"><i class="fas fa-star mr-1"></i>Starlark</span>' : ''}
|
||||
${plugin.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : ''}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
|
||||
@@ -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 = `<div class="bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-800">
|
||||
<i class="fas fa-check-circle mr-2"></i>Pixlet available${data.pixlet_version ? ' (' + escapeHtml(data.pixlet_version) + ')' : ''} — ${data.installed_apps || 0} app(s) installed
|
||||
</div>`;
|
||||
} else {
|
||||
banner.innerHTML = `<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>Pixlet not installed.
|
||||
<button onclick="window.installPixlet()" class="ml-2 px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs font-semibold">Install Pixlet</button>
|
||||
</div>`;
|
||||
}
|
||||
})
|
||||
.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 = '<option value="">All Categories</option>';
|
||||
(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 = '<div class="col-span-full text-center py-8 text-gray-500"><i class="fas fa-spinner fa-spin mr-2"></i>Loading Tronbyte apps...</div>';
|
||||
|
||||
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 = `<div class="col-span-full text-center py-8 text-red-500"><i class="fas fa-exclamation-circle mr-2"></i>${escapeHtml(data.message || 'Failed to load')}</div>`;
|
||||
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 = '<div class="col-span-full text-center py-8 text-red-500"><i class="fas fa-exclamation-circle mr-2"></i>Error loading apps</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderStarlarkApps(apps, grid) {
|
||||
if (!grid) return;
|
||||
if (!apps || apps.length === 0) {
|
||||
grid.innerHTML = '<div class="col-span-full empty-state"><div class="empty-state-icon"><i class="fas fa-star"></i></div><p>No Starlark apps found</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = apps.map(app => `
|
||||
<div class="plugin-card">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center flex-wrap gap-2 mb-2">
|
||||
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(app.name || app.id)}</h4>
|
||||
<span class="badge badge-warning"><i class="fas fa-star mr-1"></i>Starlark</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
|
||||
${app.author ? `<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(app.author)}</p>` : ''}
|
||||
${app.category ? `<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(app.category)}</p>` : ''}
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(app.summary || app.desc || 'No description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:0.5rem; margin-top:1rem; padding-top:1rem; border-top:1px solid #e5e7eb;">
|
||||
<button onclick="window.installStarlarkApp('${escapeHtml(app.id)}')" class="btn bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-semibold" style="flex:1; display:flex; justify-content:center;">
|
||||
<i class="fas fa-download mr-2"></i>Install
|
||||
</button>
|
||||
<button onclick="window.open('https://github.com/tronbyt/apps/tree/main/apps/${encodeURIComponent(app.id)}', '_blank')" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-semibold" style="display:flex; justify-content:center;">
|
||||
<i class="fas fa-external-link-alt mr-1"></i>View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -179,6 +179,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Starlark Apps Section (Tronbyte Community Apps) -->
|
||||
<div id="starlark-apps-section" class="border-t border-gray-200 pt-8 mt-8">
|
||||
<div class="flex items-center justify-between mb-5 pb-3 border-b border-gray-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-bold text-gray-900"><i class="fas fa-star text-yellow-500 mr-2"></i>Starlark Apps</h3>
|
||||
<span id="starlark-apps-count" class="text-sm text-gray-500 font-medium"></span>
|
||||
</div>
|
||||
<button id="toggle-starlark-section" class="text-sm text-blue-600 hover:text-blue-800 flex items-center font-medium transition-colors">
|
||||
<i class="fas fa-chevron-down mr-1" id="starlark-section-icon"></i>
|
||||
<span>Show</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="starlark-section-content" class="hidden">
|
||||
<p class="text-sm text-gray-600 mb-4">Browse and install Starlark apps from the <a href="https://github.com/tronbyt/apps" target="_blank" class="text-blue-600 hover:text-blue-800 underline">Tronbyte community repository</a>. Requires <strong>Pixlet</strong> binary.</p>
|
||||
|
||||
<!-- Pixlet Status Banner -->
|
||||
<div id="starlark-pixlet-status" class="mb-4"></div>
|
||||
|
||||
<!-- Search/Filter -->
|
||||
<div class="flex gap-3 mb-4">
|
||||
<input type="text" id="starlark-search" placeholder="Search Starlark apps..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm">
|
||||
<select id="starlark-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm">
|
||||
<option value="">All Categories</option>
|
||||
</select>
|
||||
<button id="starlark-browse-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg whitespace-nowrap font-semibold shadow-sm">
|
||||
<i class="fas fa-search mr-2"></i>Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Upload .star file -->
|
||||
<div class="flex gap-3 mb-4">
|
||||
<button id="starlark-upload-btn" class="btn bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm">
|
||||
<i class="fas fa-upload mr-2"></i>Upload .star File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Starlark Apps Grid -->
|
||||
<div id="starlark-apps-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install from GitHub URL Section (Separate section, always visible) -->
|
||||
<div class="border-t border-gray-200 pt-8 mt-8">
|
||||
<div class="flex items-center justify-between mb-5 pb-3 border-b border-gray-200">
|
||||
|
||||
160
web_interface/templates/v3/partials/starlark_config.html
Normal file
160
web_interface/templates/v3/partials/starlark_config.html
Normal file
@@ -0,0 +1,160 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between pb-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900">
|
||||
<i class="fas fa-star text-yellow-500 mr-2"></i>{{ app_name }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">Starlark App — ID: {{ app_id }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge badge-warning"><i class="fas fa-star mr-1"></i>Starlark</span>
|
||||
{% if app_enabled %}
|
||||
<span class="badge badge-success">Enabled</span>
|
||||
{% else %}
|
||||
<span class="badge badge-error">Disabled</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">Status</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Frames:</span>
|
||||
<span class="font-medium ml-1">{{ frame_count if has_frames else 'Not rendered' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Render Interval:</span>
|
||||
<span class="font-medium ml-1">{{ render_interval }}s</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Display Duration:</span>
|
||||
<span class="font-medium ml-1">{{ display_duration }}s</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Last Render:</span>
|
||||
<span class="font-medium ml-1" id="starlark-last-render">{{ last_render_time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button onclick="forceRenderStarlarkApp('{{ app_id }}')"
|
||||
class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-semibold">
|
||||
<i class="fas fa-sync mr-2"></i>Force Render
|
||||
</button>
|
||||
<button onclick="toggleStarlarkApp('{{ app_id }}', {{ 'false' if app_enabled else 'true' }})"
|
||||
class="btn {{ 'bg-red-600 hover:bg-red-700' if app_enabled else 'bg-green-600 hover:bg-green-700' }} text-white px-4 py-2 rounded-md text-sm font-semibold">
|
||||
<i class="fas {{ 'fa-toggle-off' if app_enabled else 'fa-toggle-on' }} mr-2"></i>
|
||||
{{ 'Disable' if app_enabled else 'Enable' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- App-specific Config (if schema exists) -->
|
||||
{% if schema %}
|
||||
<div class="bg-white rounded-lg p-4 border border-gray-200">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">App Configuration</h4>
|
||||
<div id="starlark-config-form" class="space-y-4">
|
||||
{% for key, value in config.items() %}
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label>
|
||||
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
name="{{ key }}" value="{{ value }}"
|
||||
data-starlark-config="{{ key }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button onclick="saveStarlarkConfig('{{ app_id }}')"
|
||||
class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-semibold">
|
||||
<i class="fas fa-save mr-2"></i>Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% elif config %}
|
||||
<div class="bg-white rounded-lg p-4 border border-gray-200">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">App Configuration</h4>
|
||||
<div id="starlark-config-form" class="space-y-4">
|
||||
{% for key, value in config.items() %}
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label>
|
||||
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
name="{{ key }}" value="{{ value }}"
|
||||
data-starlark-config="{{ key }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button onclick="saveStarlarkConfig('{{ app_id }}')"
|
||||
class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-semibold">
|
||||
<i class="fas fa-save mr-2"></i>Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200 text-center text-gray-500 text-sm">
|
||||
<i class="fas fa-info-circle mr-1"></i>This app has no configurable settings.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function forceRenderStarlarkApp(appId) {
|
||||
fetch('/api/v3/starlark/apps/' + encodeURIComponent(appId) + '/render', {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
alert('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)');
|
||||
} else {
|
||||
alert('Render failed: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Render failed: ' + err.message));
|
||||
}
|
||||
|
||||
function toggleStarlarkApp(appId, enabled) {
|
||||
fetch('/api/v3/starlark/apps/' + encodeURIComponent(appId) + '/toggle', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({enabled: enabled})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
// Reload the config partial to reflect new state
|
||||
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
|
||||
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins();
|
||||
// Reload this partial
|
||||
const container = document.getElementById('plugin-config-starlark:' + appId);
|
||||
if (container && window.htmx) {
|
||||
htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'});
|
||||
}
|
||||
} else {
|
||||
alert('Toggle failed: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Toggle failed: ' + err.message));
|
||||
}
|
||||
|
||||
function saveStarlarkConfig(appId) {
|
||||
const inputs = document.querySelectorAll('[data-starlark-config]');
|
||||
const config = {};
|
||||
inputs.forEach(input => {
|
||||
config[input.getAttribute('data-starlark-config')] = input.value;
|
||||
});
|
||||
|
||||
fetch('/api/v3/starlark/apps/' + encodeURIComponent(appId) + '/config', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(config)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
alert('Configuration saved!');
|
||||
} else {
|
||||
alert('Save failed: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Save failed: ' + err.message));
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user