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

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>