feat(starlark): schema-driven config forms + critical security fixes

## Schema-Driven Config UI
- Render type-appropriate form inputs from schema.json (text, dropdown, toggle, color, datetime, location)
- Pre-populate config.json with schema defaults on install
- Auto-merge schema defaults when loading existing apps (handles schema updates)
- Location fields: 3-part mini-form (lat/lng/timezone) assembles into JSON
- Toggle fields: support both boolean and string "true"/"false" values
- Unsupported field types (oauth2, photo_select) show warning banners
- Fallback to raw key/value inputs for apps without schema

## Critical Security Fixes (P0)
- **Path Traversal**: Verify path safety BEFORE mkdir to prevent TOCTOU
- **Race Conditions**: Add file locking (fcntl) + atomic writes to manifest operations
- **Command Injection**: Validate config keys/values with regex before passing to Pixlet subprocess

## Major Logic Fixes (P1)
- **Config/Manifest Separation**: Store timing keys (render_interval, display_duration) ONLY in manifest
- **Location Validation**: Validate lat [-90,90] and lng [-180,180] ranges, reject malformed JSON
- **Schema Defaults Merge**: Auto-apply new schema defaults to existing app configs on load
- **Config Key Validation**: Enforce alphanumeric+underscore format, prevent prototype pollution

## Files Changed
- web_interface/templates/v3/partials/starlark_config.html — schema-driven form rendering
- plugin-repos/starlark-apps/manager.py — file locking, path safety, config validation, schema merge
- plugin-repos/starlark-apps/pixlet_renderer.py — config value sanitization
- web_interface/blueprints/api_v3.py — timing key separation, safe manifest updates

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-02-18 21:38:57 -05:00
parent 679d9cc2fe
commit 885fdeed62
4 changed files with 502 additions and 57 deletions

View File

@@ -11,6 +11,7 @@ import json
import os import os
import re import re
import time import time
import fcntl
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple from typing import Dict, Any, Optional, List, Tuple
from PIL import Image from PIL import Image
@@ -43,10 +44,13 @@ class StarlarkApp:
self.schema_file = app_dir / "schema.json" self.schema_file = app_dir / "schema.json"
self.cache_file = app_dir / "cached_render.webp" self.cache_file = app_dir / "cached_render.webp"
# Load app configuration # Load app configuration and schema
self.config = self._load_config() self.config = self._load_config()
self.schema = self._load_schema() self.schema = self._load_schema()
# Merge schema defaults into config for any missing fields
self._merge_schema_defaults()
# Runtime state # Runtime state
self.frames: Optional[List[Tuple[Image.Image, int]]] = None self.frames: Optional[List[Tuple[Image.Image, int]]] = None
self.current_frame_index = 0 self.current_frame_index = 0
@@ -73,9 +77,70 @@ class StarlarkApp:
logger.warning(f"Could not load schema for {self.app_id}: {e}") logger.warning(f"Could not load schema for {self.app_id}: {e}")
return None return None
def _merge_schema_defaults(self) -> None:
"""
Merge schema default values into config for any missing fields.
This ensures existing apps get defaults when schemas are updated with new fields.
"""
if not self.schema:
return
# Get fields from schema (handles both 'fields' and 'schema' keys)
fields = self.schema.get('fields') or self.schema.get('schema') or []
defaults_added = False
for field in fields:
if isinstance(field, dict) and 'id' in field and 'default' in field:
field_id = field['id']
# Only add if not already present in config
if field_id not in self.config:
self.config[field_id] = field['default']
defaults_added = True
logger.debug(f"Added default value for {self.app_id}.{field_id}: {field['default']}")
# Save config if we added any defaults
if defaults_added:
self.save_config()
def _validate_config(self) -> Optional[str]:
"""
Validate config values to prevent injection and ensure data integrity.
Returns:
Error message if validation fails, None if valid
"""
for key, value in self.config.items():
# Validate key format
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]{0,63}$', key):
return f"Invalid config key format: {key}"
# Validate location fields (JSON format)
if isinstance(value, str) and value.strip().startswith('{'):
try:
loc = json.loads(value)
if 'lat' in loc:
lat = float(loc['lat'])
if not -90 <= lat <= 90:
return f"Latitude {lat} out of range [-90, 90] for key {key}"
if 'lng' in loc:
lng = float(loc['lng'])
if not -180 <= lng <= 180:
return f"Longitude {lng} out of range [-180, 180] for key {key}"
except (json.JSONDecodeError, ValueError, KeyError) as e:
# Not a location field, that's fine
pass
return None
def save_config(self) -> bool: def save_config(self) -> bool:
"""Save current configuration to file.""" """Save current configuration to file with validation."""
try: try:
# Validate config before saving
error = self._validate_config()
if error:
logger.error(f"Config validation failed for {self.app_id}: {error}")
return False
with open(self.config_file, 'w') as f: with open(self.config_file, 'w') as f:
json.dump(self.config, f, indent=2) json.dump(self.config, f, indent=2)
return True return True
@@ -484,13 +549,63 @@ class StarlarkAppsPlugin(BasePlugin):
self.logger.exception(f"Error loading apps manifest: {e}") self.logger.exception(f"Error loading apps manifest: {e}")
def _save_manifest(self, manifest: Dict[str, Any]) -> bool: def _save_manifest(self, manifest: Dict[str, Any]) -> bool:
"""Save apps manifest to file.""" """
Save apps manifest to file with file locking to prevent race conditions.
Uses exclusive lock during write to prevent concurrent modifications.
"""
try: try:
with open(self.manifest_file, 'w') as f: # Use atomic write pattern: write to temp file, then rename
json.dump(manifest, f, indent=2) temp_file = self.manifest_file.with_suffix('.tmp')
with open(temp_file, 'w') as f:
# Acquire exclusive lock during write
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
json.dump(manifest, f, indent=2)
f.flush()
os.fsync(f.fileno()) # Ensure data is written to disk
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
# Atomic rename (overwrites destination)
temp_file.replace(self.manifest_file)
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Error saving manifest: {e}") self.logger.error(f"Error saving manifest: {e}")
# Clean up temp file if it exists
if temp_file.exists():
try:
temp_file.unlink()
except:
pass
return False
def _update_manifest_safe(self, updater_fn) -> bool:
"""
Safely update manifest with file locking to prevent race conditions.
Args:
updater_fn: Function that takes manifest dict and modifies it in-place
Returns:
True if successful, False otherwise
"""
try:
# Read current manifest with shared lock
with open(self.manifest_file, 'r') as f:
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
try:
manifest = json.load(f)
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
# Apply updates
updater_fn(manifest)
# Write back with exclusive lock (handled by _save_manifest)
return self._save_manifest(manifest)
except Exception as e:
self.logger.error(f"Error updating manifest: {e}")
return False return False
def update(self) -> None: def update(self) -> None:
@@ -585,10 +700,14 @@ class StarlarkAppsPlugin(BasePlugin):
magnify = self._get_effective_magnify() magnify = self._get_effective_magnify()
self.logger.debug(f"Using magnify={magnify} for {app.app_id}") self.logger.debug(f"Using magnify={magnify} for {app.app_id}")
# Filter out LEDMatrix-internal timing keys before passing to pixlet
INTERNAL_KEYS = {'render_interval', 'display_duration'}
pixlet_config = {k: v for k, v in app.config.items() if k not in INTERNAL_KEYS}
success, error = self.pixlet.render( success, error = self.pixlet.render(
star_file=str(app.star_file), star_file=str(app.star_file),
output_path=str(app.cache_file), output_path=str(app.cache_file),
config=app.config, config=pixlet_config,
magnify=magnify magnify=magnify
) )
@@ -697,10 +816,10 @@ class StarlarkAppsPlugin(BasePlugin):
# Create app directory with resolved path # Create app directory with resolved path
app_dir = (self.apps_dir / safe_app_id).resolve() app_dir = (self.apps_dir / safe_app_id).resolve()
app_dir.mkdir(parents=True, exist_ok=True)
# Verify path safety after mkdir # Verify path safety BEFORE creating directories
self._verify_path_safety(app_dir, self.apps_dir) self._verify_path_safety(app_dir, self.apps_dir)
app_dir.mkdir(parents=True, exist_ok=True)
# Copy .star file with sanitized app_id # Copy .star file with sanitized app_id
star_dest = app_dir / f"{safe_app_id}.star" star_dest = app_dir / f"{safe_app_id}.star"
@@ -727,8 +846,13 @@ class StarlarkAppsPlugin(BasePlugin):
with open(schema_path, 'w') as f: with open(schema_path, 'w') as f:
json.dump(schema, f, indent=2) json.dump(schema, f, indent=2)
# Create default config # Create default config — pre-populate with schema defaults
default_config = {} default_config = {}
if schema:
fields = schema.get('fields') or schema.get('schema') or []
for field in fields:
if isinstance(field, dict) and 'id' in field and 'default' in field:
default_config[field['id']] = field['default']
config_path = app_dir / "config.json" config_path = app_dir / "config.json"
# Verify config path safety # Verify config path safety
self._verify_path_safety(config_path, self.apps_dir) self._verify_path_safety(config_path, self.apps_dir)
@@ -736,11 +860,10 @@ class StarlarkAppsPlugin(BasePlugin):
json.dump(default_config, f, indent=2) json.dump(default_config, f, indent=2)
# Update manifest (use safe_app_id as key to match directory) # Update manifest (use safe_app_id as key to match directory)
with open(self.manifest_file, 'r') as f: def update_fn(manifest):
manifest = json.load(f) manifest["apps"][safe_app_id] = app_manifest
manifest["apps"][safe_app_id] = app_manifest self._update_manifest_safe(update_fn)
self._save_manifest(manifest)
# Create app instance (use safe_app_id for internal key, original for display) # Create app instance (use safe_app_id for internal key, original for display)
app = StarlarkApp(safe_app_id, app_dir, app_manifest) app = StarlarkApp(safe_app_id, app_dir, app_manifest)
@@ -782,12 +905,11 @@ class StarlarkAppsPlugin(BasePlugin):
shutil.rmtree(app.app_dir) shutil.rmtree(app.app_dir)
# Update manifest # Update manifest
with open(self.manifest_file, 'r') as f: def update_fn(manifest):
manifest = json.load(f) if app_id in manifest["apps"]:
del manifest["apps"][app_id]
if app_id in manifest["apps"]: self._update_manifest_safe(update_fn)
del manifest["apps"][app_id]
self._save_manifest(manifest)
self.logger.info(f"Uninstalled Starlark app: {app_id}") self.logger.info(f"Uninstalled Starlark app: {app_id}")
return True return True

View File

@@ -9,6 +9,7 @@ import json
import logging import logging
import os import os
import platform import platform
import re
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -250,11 +251,23 @@ class PixletRenderer:
# Add configuration parameters # Add configuration parameters
if config: if config:
for key, value in config.items(): for key, value in config.items():
# Validate key format (alphanumeric + underscore only)
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', key):
logger.warning(f"Skipping invalid config key: {key}")
continue
# Convert value to string for CLI # Convert value to string for CLI
if isinstance(value, bool): if isinstance(value, bool):
value_str = "true" if value else "false" value_str = "true" if value else "false"
else: else:
value_str = str(value) value_str = str(value)
# Validate value doesn't contain shell metacharacters
# Allow alphanumeric, spaces, and common safe chars: .-_:/@#,
if not re.match(r'^[a-zA-Z0-9 .\-_:/@#,{}"\[\]]*$', value_str):
logger.warning(f"Skipping config value with unsafe characters for key {key}: {value_str}")
continue
cmd.extend(["-c", f"{key}={value_str}"]) cmd.extend(["-c", f"{key}={value_str}"])
logger.debug(f"Executing Pixlet: {' '.join(cmd)}") logger.debug(f"Executing Pixlet: {' '.join(cmd)}")

View File

@@ -7374,8 +7374,38 @@ def update_starlark_app_config(app_id):
app = starlark_plugin.apps.get(app_id) app = starlark_plugin.apps.get(app_id)
if not app: if not app:
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
# Extract timing keys from data before updating config (they belong in manifest, not config)
render_interval = data.pop('render_interval', None)
display_duration = data.pop('display_duration', None)
# Update config with non-timing fields only
app.config.update(data) app.config.update(data)
# Update manifest with timing fields
timing_changed = False
if render_interval is not None:
app.manifest['render_interval'] = render_interval
timing_changed = True
if display_duration is not None:
app.manifest['display_duration'] = display_duration
timing_changed = True
if app.save_config(): if app.save_config():
# Persist manifest if timing changed (same pattern as toggle endpoint)
if timing_changed:
try:
# Use safe manifest update to prevent race conditions
timing_updates = {}
if render_interval is not None:
timing_updates['render_interval'] = render_interval
if display_duration is not None:
timing_updates['display_duration'] = display_duration
def update_fn(manifest):
manifest['apps'][app_id].update(timing_updates)
starlark_plugin._update_manifest_safe(update_fn)
except Exception as e:
logger.warning(f"Failed to persist timing to manifest for {app_id}: {e}")
starlark_plugin._render_app(app, force=True) starlark_plugin._render_app(app, force=True)
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': app.config}) return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': app.config})
else: else:
@@ -7418,10 +7448,10 @@ def toggle_starlark_app(app_id):
if enabled is None: if enabled is None:
enabled = not app.is_enabled() enabled = not app.is_enabled()
app.manifest['enabled'] = enabled app.manifest['enabled'] = enabled
with open(starlark_plugin.manifest_file, 'r') as f: # Use safe manifest update to prevent race conditions
manifest = json.load(f) def update_fn(manifest):
manifest['apps'][app_id]['enabled'] = enabled manifest['apps'][app_id]['enabled'] = enabled
starlark_plugin._save_manifest(manifest) starlark_plugin._update_manifest_safe(update_fn)
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled}) return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
# Standalone: update manifest directly # Standalone: update manifest directly

View File

@@ -53,7 +53,7 @@
</button> </button>
</div> </div>
<!-- Timing Settings (always shown) --> <!-- Configuration -->
<div class="bg-white rounded-lg p-4 border border-gray-200"> <div class="bg-white rounded-lg p-4 border border-gray-200">
<h4 class="text-sm font-semibold text-gray-700 mb-3">Timing Settings</h4> <h4 class="text-sm font-semibold text-gray-700 mb-3">Timing Settings</h4>
<div id="starlark-config-form" class="space-y-4"> <div id="starlark-config-form" class="space-y-4">
@@ -76,18 +76,239 @@
</div> </div>
</div> </div>
{% if schema or config %} {# ── Schema-driven App Settings ── #}
{% set fields = [] %}
{% if schema %}
{% if schema.fields is defined %}
{% set fields = schema.fields %}
{% elif schema.schema is defined and schema.schema is iterable and schema.schema is not string %}
{% set fields = schema.schema %}
{% endif %}
{% endif %}
{% if fields %}
<hr class="border-gray-200 my-2">
<h4 class="text-sm font-semibold text-gray-700 mb-2">App Settings</h4>
{% for field in fields %}
{% if field.typeOf is defined and field.id is defined %}
{% set field_id = field.id %}
{% set field_type = field.typeOf %}
{% set field_name = field.name or field_id %}
{% set field_desc = field.desc or '' %}
{% set field_default = field.default if field.default is defined else '' %}
{% set current_val = config.get(field_id, field_default) if config else field_default %}
{# ── text ── #}
{% if field_type == 'text' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
placeholder="{{ field_desc }}"
data-starlark-config="{{ field_id }}">
{% if field_desc %}
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
{% endif %}
</div>
{# ── dropdown ── #}
{% elif field_type == 'dropdown' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<select class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white"
data-starlark-config="{{ field_id }}">
{% for opt in (field.options or []) %}
<option value="{{ opt.value }}" {{ 'selected' if current_val|string == opt.value|string else '' }}>
{{ opt.display }}
</option>
{% endfor %}
</select>
{% if field_desc %}
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
{% endif %}
</div>
{# ── toggle ── #}
{% elif field_type == 'toggle' %}
<div class="form-group">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
<input type="checkbox"
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
data-starlark-config="{{ field_id }}"
data-starlark-type="toggle"
{{ 'checked' if (current_val is sameas true or current_val|string|lower in ('true', '1', 'yes')) else '' }}>
{{ field_name }}
</label>
{% if field_desc %}
<p class="text-xs text-gray-400 mt-1 ml-6">{{ field_desc }}</p>
{% endif %}
</div>
{# ── color ── #}
{% elif field_type == 'color' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="flex items-center gap-2">
<input type="color"
class="w-10 h-10 rounded border border-gray-300 cursor-pointer p-0.5"
value="{{ current_val or '#FFFFFF' }}"
data-starlark-color-picker="{{ field_id }}"
oninput="document.querySelector('[data-starlark-config={{ field_id }}]').value = this.value">
<input type="text"
class="form-control flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono"
value="{{ current_val or '#FFFFFF' }}"
placeholder="#RRGGBB"
data-starlark-config="{{ field_id }}"
oninput="var cp = document.querySelector('[data-starlark-color-picker={{ field_id }}]'); if(this.value.match(/^#[0-9a-fA-F]{6}$/)) cp.value = this.value;">
</div>
{% if field_desc %}
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
{% endif %}
</div>
{# ── datetime ── #}
{% elif field_type == 'datetime' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="datetime-local"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
data-starlark-config="{{ field_id }}">
{% if field_desc %}
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
{% endif %}
</div>
{# ── location (mini-form) ── #}
{% elif field_type == 'location' %}
<div class="form-group" data-starlark-location-group="{{ field_id }}" data-starlark-location-value="{{ current_val }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<div>
<input type="number" step="any" min="-90" max="90"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Latitude"
data-starlark-location-field="{{ field_id }}"
data-starlark-location-key="lat">
</div>
<div>
<input type="number" step="any" min="-180" max="180"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Longitude"
data-starlark-location-field="{{ field_id }}"
data-starlark-location-key="lng">
</div>
<div>
<input type="text"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Timezone (e.g. America/New_York)"
data-starlark-location-field="{{ field_id }}"
data-starlark-location-key="timezone">
</div>
</div>
{% if field_desc %}
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
{% endif %}
</div>
{# ── oauth2 (unsupported) ── #}
{% elif field_type == 'oauth2' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-3 text-sm text-yellow-800" data-starlark-unsupported>
<i class="fas fa-exclamation-triangle mr-1"></i>
This app requires OAuth2 authentication, which is not supported in standalone mode.
</div>
{% if field_desc %}
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
{% endif %}
</div>
{# ── photo_select (unsupported) ── #}
{% elif field_type == 'photo_select' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-3 text-sm text-yellow-800" data-starlark-unsupported>
<i class="fas fa-exclamation-triangle mr-1"></i>
Photo upload is not supported in this interface.
</div>
</div>
{# ── generated (hidden meta-field, skip) ── #}
{% elif field_type == 'generated' %}
{# Invisible — generated fields are handled server-side by Pixlet #}
{# ── typeahead / location_based (text fallback with note) ── #}
{% elif field_type in ('typeahead', 'location_based') %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
placeholder="{{ field_desc }}"
data-starlark-config="{{ field_id }}">
<p class="text-xs text-yellow-600 mt-1">
<i class="fas fa-info-circle mr-1"></i>
This field normally uses autocomplete which requires a Pixlet server. Enter the value manually.
</p>
{% if field_desc %}
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
{% endif %}
</div>
{# ── unknown type (text fallback) ── #}
{% else %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
placeholder="{{ field_desc }}"
data-starlark-config="{{ field_id }}">
{% if field_desc %}
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
{% endif %}
</div>
{% endif %}
{% endif %}{# end field.typeOf and field.id check #}
{% endfor %}
{# Also show any config keys NOT in the schema (user-added or legacy) #}
{% if config %}
{% set schema_ids = [] %}
{% for f in fields %}
{% if f.id is defined %}
{% if schema_ids.append(f.id) %}{% endif %}
{% endif %}
{% endfor %}
{% for key, value in config.items() %}
{% if key not in ('render_interval', 'display_duration') and key not in schema_ids %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }} <span class="text-xs text-gray-400">(custom)</span></label>
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ value }}"
data-starlark-config="{{ key }}">
</div>
{% endif %}
{% endfor %}
{% endif %}
{# ── No schema: fall back to raw config key/value pairs ── #}
{% elif config %}
<hr class="border-gray-200 my-2"> <hr class="border-gray-200 my-2">
<h4 class="text-sm font-semibold text-gray-700 mb-2">App Settings</h4> <h4 class="text-sm font-semibold text-gray-700 mb-2">App Settings</h4>
{% for key, value in config.items() %} {% for key, value in config.items() %}
{% if key not in ('render_interval', 'display_duration') %} {% if key not in ('render_interval', 'display_duration') %}
<div class="form-group"> <div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label> <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" <input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
name="{{ key }}" value="{{ value }}" value="{{ value }}"
data-starlark-config="{{ key }}"> data-starlark-config="{{ key }}">
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
@@ -102,15 +323,25 @@
<script> <script>
function forceRenderStarlarkApp(appId) { function forceRenderStarlarkApp(appId) {
fetch('/api/v3/starlark/apps/' + encodeURIComponent(appId) + '/render', {method: 'POST'}) fetch('/api/v3/starlark/apps/' + encodeURIComponent(appId) + '/render', {method: 'POST'})
.then(r => r.json()) .then(function(r) { return r.json(); })
.then(data => { .then(function(data) {
if (data.status === 'success') { if (data.status === 'success') {
alert('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)'); if (typeof showNotification === 'function') {
showNotification('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)', 'success');
} else {
alert('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)');
}
} else { } else {
alert('Render failed: ' + (data.message || 'Unknown error')); var msg = 'Render failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
} }
}) })
.catch(err => alert('Render failed: ' + err.message)); .catch(function(err) {
var msg = 'Render failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
});
} }
function toggleStarlarkApp(appId, enabled) { function toggleStarlarkApp(appId, enabled) {
@@ -119,35 +350,55 @@ function toggleStarlarkApp(appId, enabled) {
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: enabled}) body: JSON.stringify({enabled: enabled})
}) })
.then(r => r.json()) .then(function(r) { return r.json(); })
.then(data => { .then(function(data) {
if (data.status === 'success') { if (data.status === 'success') {
// Reload the config partial to reflect new state
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins(); else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins();
// Reload this partial var container = document.getElementById('plugin-config-starlark:' + appId);
const container = document.getElementById('plugin-config-starlark:' + appId);
if (container && window.htmx) { if (container && window.htmx) {
htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'}); htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'});
} }
} else { } else {
alert('Toggle failed: ' + (data.message || 'Unknown error')); var msg = 'Toggle failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
} }
}) })
.catch(err => alert('Toggle failed: ' + err.message)); .catch(function(err) {
var msg = 'Toggle failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
});
} }
function saveStarlarkConfig(appId) { function saveStarlarkConfig(appId) {
const inputs = document.querySelectorAll('[data-starlark-config]'); var config = {};
const config = {};
inputs.forEach(input => { // Collect standard inputs (text, number, select, datetime, color text companion)
const key = input.getAttribute('data-starlark-config'); document.querySelectorAll('[data-starlark-config]').forEach(function(input) {
const val = input.value; var key = input.getAttribute('data-starlark-config');
// Send timing fields as integers var type = input.getAttribute('data-starlark-type');
if (key === 'render_interval' || key === 'display_duration') { if (key === 'render_interval' || key === 'display_duration') {
config[key] = parseInt(val, 10) || 0; config[key] = parseInt(input.value, 10) || 0;
} else if (type === 'toggle') {
config[key] = input.checked ? 'true' : 'false';
} else { } else {
config[key] = val; config[key] = input.value;
}
});
// Collect location mini-form groups
document.querySelectorAll('[data-starlark-location-group]').forEach(function(group) {
var fieldId = group.getAttribute('data-starlark-location-group');
var loc = {};
group.querySelectorAll('[data-starlark-location-field="' + fieldId + '"]').forEach(function(sub) {
var locKey = sub.getAttribute('data-starlark-location-key');
if (sub.value) loc[locKey] = sub.value;
});
if (Object.keys(loc).length > 0) {
config[fieldId] = JSON.stringify(loc);
} }
}); });
@@ -156,14 +407,43 @@ function saveStarlarkConfig(appId) {
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config) body: JSON.stringify(config)
}) })
.then(r => r.json()) .then(function(r) { return r.json(); })
.then(data => { .then(function(data) {
if (data.status === 'success') { if (data.status === 'success') {
alert('Configuration saved!'); if (typeof showNotification === 'function') showNotification('Configuration saved!', 'success');
else alert('Configuration saved!');
// Reload partial to reflect updated status
var 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 { } else {
alert('Save failed: ' + (data.message || 'Unknown error')); var msg = 'Save failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
} }
}) })
.catch(err => alert('Save failed: ' + err.message)); .catch(function(err) {
var msg = 'Save failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
});
} }
// Pre-fill location fields from stored JSON config values
(function() {
document.querySelectorAll('[data-starlark-location-group]').forEach(function(group) {
var fieldId = group.getAttribute('data-starlark-location-group');
// Find the hidden or stored value — look for a data attribute with the raw JSON
var rawVal = group.getAttribute('data-starlark-location-value');
if (!rawVal) return;
try {
var loc = JSON.parse(rawVal);
group.querySelectorAll('[data-starlark-location-field="' + fieldId + '"]').forEach(function(sub) {
var locKey = sub.getAttribute('data-starlark-location-key');
if (loc[locKey] !== undefined) sub.value = loc[locKey];
});
} catch(e) { /* not valid JSON, ignore */ }
});
})();
</script> </script>