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:
Chuck
2026-02-18 13:27:22 -05:00
parent 963c4d3b91
commit 3ec1e987a4
15 changed files with 3198 additions and 14 deletions

3
.gitignore vendored
View File

@@ -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/

View 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"

View 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
}

View 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

View 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

View 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"
]
}

View 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"

View File

@@ -0,0 +1,3 @@
Pillow>=10.0.0
PyYAML>=6.0
requests>=2.31.0

View 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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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) + ')' : ''} &mdash; ${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();
}
});
})();

View File

@@ -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">

View 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 &mdash; 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>