mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
Six Starlark fixes are on main via #535 and #537, but a follow-up commit carrying tests for half of them was pushed to fix/starlark-pixlet-install six minutes after #535 merged, so those tests never landed. This ports them onto the api_v3 package split: - a failed toggle write answers 500, and a loaded app is not flipped in memory when the manifest write fails - each manifest writer gets its own temp file; concurrent writes leave readable JSON; no temp files are left behind - a failed dynamic import of tronbyte_repository / pixlet_renderer does not stay cached in sys.modules - a failed save_config() leaves config and timing untouched and does not re-render; a successful save still applies It also logs when the timing update to the manifest is not persisted. _update_manifest_safe answers False rather than raising, so the existing except branch never saw that failure. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1000 lines
48 KiB
Python
1000 lines
48 KiB
Python
"""Starlark / Tronbyte app management routes.
|
|
|
|
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
|
|
so their endpoint names are unchanged by living here.
|
|
"""
|
|
import signal
|
|
import threading
|
|
|
|
from web_interface.blueprints.api_v3 import (
|
|
PROJECT_ROOT, Path, _PIXLET_EDITOR_DEFAULT_PORT,
|
|
_PIXLET_EDITOR_DEFAULT_TIMEOUT, _PIXLET_EDITOR_MAX_TIMEOUT,
|
|
_PIXLET_EDITOR_SCRIPT, _PIXLET_EDITOR_STATE, _clear_pixlet_editor_state,
|
|
_find_pixlet_binary, _install_star_file, _pixlet_editor_alive,
|
|
_run_systemctl_command,
|
|
_pixlet_editor_status, _read_pixlet_editor_state,
|
|
_STARLARK_APPS_DIR, _standalone_render_starlark_app,
|
|
_starlark_github_token, _starlark_manifest_lock,
|
|
_validate_and_sanitize_app_id,
|
|
_validate_starlark_app_path, _validate_timing_value, api_v3, contextlib,
|
|
describe_exception, json, jsonify, logger, os, request, shutil,
|
|
subprocess, tempfile,
|
|
)
|
|
import web_interface.blueprints.api_v3 as _pkg
|
|
from web_interface.blueprints.api_v3.wifi import _parse_bool_ish
|
|
# Read through the module rather than bound by value: tests patch these
|
|
# as module attributes, and a value binding would not see the patch.
|
|
# Several are also called from helpers that live in __init__, so the
|
|
# package is the only patch point that covers every caller.
|
|
|
|
|
|
def _ownership_hint(err: BaseException):
|
|
"""An actionable message when the apps directory is not writable.
|
|
|
|
The display service runs as root and the web interface as the login user
|
|
(see systemd/ledmatrix.service and systemd/ledmatrix-web.service). The
|
|
starlark-apps directory is not in the repository, so whichever service
|
|
reaches it first creates it -- and when that is the display service, the
|
|
web user cannot write into it and every install fails.
|
|
|
|
The plugin now hands the directory back on startup, so this should not be
|
|
reachable. It is kept because the failure is otherwise invisible: the
|
|
generic message names no path and no cause, and the one user who hit it
|
|
had to read the service logs to find it. If the handover is ever prevented
|
|
-- an exotic mount, a directory root-owned for another reason -- this says
|
|
what to do instead of costing somebody an evening.
|
|
|
|
Returns None when `err` is not a permission problem.
|
|
"""
|
|
if not isinstance(err, PermissionError):
|
|
return None
|
|
return (
|
|
f"Cannot write to {_STARLARK_APPS_DIR}. It is owned by another user "
|
|
f"-- usually because the display service, which runs as root, created "
|
|
f"it before the web interface did. Restarting the display service "
|
|
f"(sudo systemctl restart ledmatrix) repairs the ownership "
|
|
f"automatically. To fix it by hand: "
|
|
f"sudo chown -R $USER:$USER {_STARLARK_APPS_DIR}"
|
|
)
|
|
|
|
|
|
@api_v3.route('/starlark/status', methods=['GET'])
|
|
def get_starlark_status():
|
|
"""Get Starlark plugin status and Pixlet availability."""
|
|
try:
|
|
starlark_plugin = _pkg._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 via shared resolver
|
|
# (respects user-configured pixlet_path, bundled binary, and system PATH)
|
|
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
|
pixlet_path = _find_pixlet_binary(full_config.get('starlark-apps', {}).get('pixlet_path'))
|
|
pixlet_available = pixlet_path is not None
|
|
|
|
# Read app counts from manifest
|
|
manifest = _pkg._read_starlark_manifest()
|
|
apps = manifest.get('apps', {})
|
|
installed_count = len(apps)
|
|
enabled_count = sum(1 for a in apps.values() if a.get('enabled', True))
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'pixlet_available': pixlet_available,
|
|
'pixlet_version': None,
|
|
'installed_apps': installed_count,
|
|
'enabled_apps': enabled_count,
|
|
'plugin_enabled': True,
|
|
'plugin_loaded': False,
|
|
'display_info': {}
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.exception("[Starlark] get_starlark_status failed")
|
|
return jsonify({'status': 'error', 'message': 'Failed to get Starlark status', 'details': describe_exception(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 as err:
|
|
logger.exception("[Starlark] Pixlet download timed out")
|
|
return jsonify({'status': 'error', 'message': 'Download timed out',
|
|
'details': describe_exception(err)}), 500
|
|
except Exception as e:
|
|
logger.exception("[Starlark] install_pixlet failed")
|
|
return jsonify({'status': 'error', 'message': 'Failed to install Pixlet', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/starlark/apps', methods=['GET'])
|
|
def get_starlark_apps():
|
|
"""List all installed Starlark apps."""
|
|
try:
|
|
starlark_plugin = _pkg._get_starlark_plugin()
|
|
if starlark_plugin:
|
|
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)})
|
|
|
|
# Standalone: read manifest from disk
|
|
manifest = _pkg._read_starlark_manifest()
|
|
apps_list = []
|
|
for app_id, app_data in manifest.get('apps', {}).items():
|
|
apps_list.append({
|
|
'id': app_id,
|
|
'name': app_data.get('name', app_id),
|
|
'enabled': app_data.get('enabled', True),
|
|
'has_frames': False,
|
|
'render_interval': app_data.get('render_interval', 300),
|
|
'display_duration': app_data.get('display_duration', 15),
|
|
'config': app_data.get('config', {}),
|
|
'has_schema': False,
|
|
'last_render_time': None
|
|
})
|
|
return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)})
|
|
|
|
except Exception as e:
|
|
logger.exception("[Starlark] get_starlark_apps failed")
|
|
return jsonify({'status': 'error', 'message': 'Failed to get Starlark apps', 'details': describe_exception(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:
|
|
# Validate app_id before any filesystem access
|
|
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
|
if error_msg:
|
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
|
|
|
starlark_plugin = _pkg._get_starlark_plugin()
|
|
if starlark_plugin:
|
|
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,
|
|
}
|
|
})
|
|
|
|
# Standalone: read from manifest
|
|
manifest = _pkg._read_starlark_manifest()
|
|
app_data = manifest.get('apps', {}).get(app_id)
|
|
if not app_data:
|
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
|
|
|
# Load schema from schema.json if it exists (path already validated above)
|
|
schema = None
|
|
schema_file = app_dir / 'schema.json'
|
|
if schema_file.exists():
|
|
try:
|
|
with open(schema_file, 'r') as f:
|
|
schema = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logger.warning(f"Failed to load schema for {app_id}: {e}")
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'app': {
|
|
'id': app_id,
|
|
'name': app_data.get('name', app_id),
|
|
'enabled': app_data.get('enabled', True),
|
|
'config': app_data.get('config', {}),
|
|
'schema': schema,
|
|
'render_interval': app_data.get('render_interval', 300),
|
|
'display_duration': app_data.get('display_duration', 15),
|
|
'has_frames': False,
|
|
'frame_count': 0,
|
|
'last_render_time': None,
|
|
}
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.exception("[Starlark] get_starlark_app failed")
|
|
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/starlark/upload', methods=['POST'])
|
|
def upload_starlark_app():
|
|
"""Upload and install a new Starlark app."""
|
|
try:
|
|
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
|
|
|
|
# Check file size (limit to 5MB for .star files)
|
|
file.seek(0, 2) # Seek to end
|
|
file_size = file.tell()
|
|
file.seek(0) # Reset to beginning
|
|
MAX_STAR_SIZE = 5 * 1024 * 1024 # 5MB
|
|
if file_size > MAX_STAR_SIZE:
|
|
return jsonify({'status': 'error', 'message': f'File too large (max 5MB, got {file_size/1024/1024:.1f}MB)'}), 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}
|
|
starlark_plugin = _pkg._get_starlark_plugin()
|
|
if starlark_plugin:
|
|
success = starlark_plugin.install_app(app_id, temp_path, metadata)
|
|
else:
|
|
success = _install_star_file(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 (OSError, IOError) as err:
|
|
# This used to withhold the detail because it names absolute paths on
|
|
# the device. A full disk and a bad permission are indistinguishable
|
|
# without it, though, and describe_exception redacts credentials and
|
|
# truncates -- the same trade-off every other handler here makes.
|
|
logger.exception("[Starlark] File error uploading starlark app: %s", err)
|
|
return jsonify({'status': 'error',
|
|
'message': _ownership_hint(err) or 'File error during upload',
|
|
'details': describe_exception(err)}), 500
|
|
except ImportError as err:
|
|
logger.exception("[Starlark] Module load error uploading starlark app: %s", err)
|
|
return jsonify({'status': 'error', 'message': 'Failed to load app module', 'details': describe_exception(err)}), 500
|
|
except Exception as err:
|
|
logger.exception("[Starlark] Unexpected error uploading starlark app: %s", err)
|
|
return jsonify({'status': 'error', 'message': 'Failed to upload app', 'details': describe_exception(err)}), 500
|
|
@api_v3.route('/starlark/apps/<app_id>', methods=['DELETE'])
|
|
def uninstall_starlark_app(app_id):
|
|
"""Uninstall a Starlark app."""
|
|
try:
|
|
# Validate app_id before any filesystem access
|
|
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
|
if error_msg:
|
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
|
|
|
starlark_plugin = _pkg._get_starlark_plugin()
|
|
if starlark_plugin:
|
|
success = starlark_plugin.uninstall_app(app_id)
|
|
else:
|
|
# Standalone: remove app dir and manifest entry. app_dir is the
|
|
# path _validate_starlark_app_path checked, not a fresh join.
|
|
if app_dir.exists():
|
|
shutil.rmtree(app_dir)
|
|
with _starlark_manifest_lock():
|
|
manifest = _pkg._read_starlark_manifest()
|
|
manifest.get('apps', {}).pop(app_id, None)
|
|
success = _pkg._write_starlark_manifest(manifest)
|
|
|
|
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.exception("[Starlark] uninstall_starlark_app failed")
|
|
return jsonify({'status': 'error', 'message': 'Failed to uninstall Starlark app', 'details': describe_exception(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:
|
|
# Validate app_id before any filesystem access
|
|
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
|
if error_msg:
|
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
|
|
|
starlark_plugin = _pkg._get_starlark_plugin()
|
|
if starlark_plugin:
|
|
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})
|
|
|
|
# Standalone: read from config.json. app_dir is the path
|
|
# _validate_starlark_app_path checked, not a fresh join.
|
|
config_file = app_dir / "config.json"
|
|
|
|
if not app_dir.exists():
|
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
|
|
|
config = {}
|
|
if config_file.exists():
|
|
try:
|
|
with open(config_file, 'r') as f:
|
|
config = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logger.warning(f"Failed to load config for {app_id}: {e}")
|
|
|
|
# Load schema from schema.json
|
|
schema = None
|
|
schema_file = app_dir / "schema.json"
|
|
if schema_file.exists():
|
|
try:
|
|
with open(schema_file, 'r') as f:
|
|
schema = json.load(f)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load schema for {app_id}: {e}")
|
|
|
|
return jsonify({'status': 'success', 'config': config, 'schema': schema})
|
|
|
|
except Exception as e:
|
|
logger.exception("[Starlark] get_starlark_app_config failed")
|
|
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app config', 'details': describe_exception(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:
|
|
# Validate app_id before any filesystem access
|
|
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
|
if error_msg:
|
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
|
|
|
data = request.get_json(silent=True)
|
|
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
|
|
|
|
starlark_plugin = _pkg._get_starlark_plugin()
|
|
if starlark_plugin:
|
|
app = starlark_plugin.apps.get(app_id)
|
|
if not app:
|
|
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)
|
|
|
|
# Snapshot before mutating. save_config() can fail, and the route
|
|
# answers 500 below when it does -- but the loaded app kept the new
|
|
# values anyway, so a later GET returned configuration that was
|
|
# never persisted and the plugin rendered with it.
|
|
prev_config = dict(app.config)
|
|
prev_manifest = dict(app.manifest)
|
|
|
|
# Update config with non-timing fields only
|
|
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
|
|
saved = app.save_config()
|
|
if not saved:
|
|
app.config.clear(); app.config.update(prev_config)
|
|
app.manifest.clear(); app.manifest.update(prev_manifest)
|
|
if saved:
|
|
# 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)
|
|
# _update_manifest_safe answers False rather than
|
|
# raising, so the except below never sees this failure.
|
|
if starlark_plugin._update_manifest_safe(update_fn) is False:
|
|
logger.warning("Timing for %s was not persisted to the manifest", app_id)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to persist timing to manifest for {app_id}: {e}")
|
|
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
|
|
|
|
# Standalone: update both config.json and manifest. Both live under
|
|
# the manifest lock (serialized against every other standalone
|
|
# manifest read-modify-write -- see _starlark_manifest_lock), and if
|
|
# the manifest write fails after config.json was already written,
|
|
# config.json is rolled back so the two do not end up out of sync.
|
|
with _starlark_manifest_lock():
|
|
manifest = _pkg._read_starlark_manifest()
|
|
app_data = manifest.get('apps', {}).get(app_id)
|
|
if not app_data:
|
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
|
|
|
# Extract timing keys (they go in manifest, not config.json)
|
|
render_interval = data.pop('render_interval', None)
|
|
display_duration = data.pop('display_duration', None)
|
|
|
|
# Update manifest with timing values
|
|
if render_interval is not None:
|
|
app_data['render_interval'] = render_interval
|
|
if display_duration is not None:
|
|
app_data['display_duration'] = display_duration
|
|
|
|
# Load current config from config.json. app_dir is the path
|
|
# _validate_starlark_app_path checked, not a fresh join.
|
|
config_file = app_dir / "config.json"
|
|
existed_before = config_file.exists()
|
|
previous_config_bytes = None
|
|
current_config = {}
|
|
if existed_before:
|
|
try:
|
|
previous_config_bytes = config_file.read_bytes()
|
|
current_config = json.loads(previous_config_bytes)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load config for {app_id}: {e}")
|
|
|
|
# Update config with new values (excluding timing keys)
|
|
current_config.update(data)
|
|
|
|
# Write updated config to config.json
|
|
try:
|
|
with open(config_file, 'w') as f:
|
|
json.dump(current_config, f, indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Failed to save config.json for {app_id}: {e}")
|
|
logger.exception("Failed to save Starlark configuration for %r", app_id)
|
|
return jsonify({'status': 'error', 'message': 'Failed to save configuration',
|
|
'details': describe_exception(e)}), 500
|
|
|
|
# Also update manifest for backward compatibility
|
|
app_data.setdefault('config', {}).update(data)
|
|
|
|
if _pkg._write_starlark_manifest(manifest):
|
|
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': current_config})
|
|
|
|
# The manifest write failed after config.json was already
|
|
# written -- roll config.json back rather than leave the two
|
|
# disagreeing about what was saved.
|
|
try:
|
|
if existed_before and previous_config_bytes is not None:
|
|
config_file.write_bytes(previous_config_bytes)
|
|
elif not existed_before:
|
|
config_file.unlink(missing_ok=True)
|
|
except OSError:
|
|
logger.exception(
|
|
"Failed to roll back config.json for %r after manifest write failure", app_id)
|
|
return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500
|
|
|
|
except Exception as e:
|
|
logger.exception("[Starlark] update_starlark_app_config failed")
|
|
return jsonify({'status': 'error', 'message': 'Failed to update Starlark app config', 'details': describe_exception(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:
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
# The write itself goes through _toggle_starlark_app, the same helper
|
|
# /plugins/toggle uses: it saves to disk before touching the loaded
|
|
# app, and tolerates a loaded app with no on-disk entry. This route
|
|
# used to carry its own copy without either fix, and stored "false"
|
|
# (a truthy string) as-is.
|
|
enabled = data.get('enabled')
|
|
if enabled is None:
|
|
_, err = _validate_starlark_app_path(app_id)
|
|
if err:
|
|
return jsonify({'status': 'error', 'message': err}), 400
|
|
# No explicit state: flip the current one.
|
|
starlark_plugin = _pkg._get_starlark_plugin()
|
|
app = starlark_plugin.apps.get(app_id) if starlark_plugin else None
|
|
if app is not None:
|
|
enabled = not app.is_enabled()
|
|
else:
|
|
app_data = _pkg._read_starlark_manifest().get('apps', {}).get(app_id)
|
|
if not app_data:
|
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
|
enabled = not app_data.get('enabled', True)
|
|
else:
|
|
enabled = _parse_bool_ish(enabled)
|
|
if enabled is None:
|
|
return jsonify({'status': 'error',
|
|
'message': 'enabled must be true or false'}), 400
|
|
|
|
return _pkg._toggle_starlark_app(app_id, enabled)
|
|
|
|
except Exception as e:
|
|
logger.exception("[Starlark] toggle_starlark_app failed")
|
|
return jsonify({'status': 'error', 'message': 'Failed to toggle Starlark app', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/starlark/apps/<app_id>/render', methods=['POST'])
|
|
def render_starlark_app(app_id):
|
|
"""Force render a Starlark app."""
|
|
try:
|
|
app_dir, err = _validate_starlark_app_path(app_id)
|
|
if err:
|
|
return jsonify({'status': 'error', 'message': err}), 400
|
|
|
|
starlark_plugin = _pkg._get_starlark_plugin()
|
|
if starlark_plugin:
|
|
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})
|
|
return jsonify({'status': 'error', 'message': 'Failed to render app'}), 500
|
|
|
|
# Web-service context: plugin not loaded, call pixlet directly
|
|
success, status_code, error = _standalone_render_starlark_app(app_id)
|
|
if success:
|
|
return jsonify({'status': 'success', 'message': 'App rendered successfully', 'frame_count': 0}), status_code
|
|
return jsonify({'status': 'error', 'message': error or 'Render failed', 'frame_count': 0}), status_code
|
|
|
|
except Exception as e:
|
|
logger.exception("[Starlark] render_starlark_app failed")
|
|
return jsonify({'status': 'error', 'message': 'Failed to render Starlark app', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/starlark/repository/browse', methods=['GET'])
|
|
def browse_tronbyte_repository():
|
|
"""Browse all apps in the Tronbyte repository (bulk cached fetch).
|
|
|
|
Returns ALL apps with metadata, categories, and authors.
|
|
Filtering/sorting/pagination is handled client-side.
|
|
Results are cached server-side for 2 hours.
|
|
"""
|
|
try:
|
|
TronbyteRepository = _pkg._get_tronbyte_repository_class()
|
|
|
|
repo = TronbyteRepository(github_token=_starlark_github_token())
|
|
|
|
result = repo.list_all_apps_cached()
|
|
|
|
rate_limit = repo.get_rate_limit_info()
|
|
|
|
# An upstream failure used to arrive here as an empty app list and go
|
|
# out as 'success', so the store drew an empty grid and said nothing.
|
|
# 502: the request was fine, GitHub was not.
|
|
if result.get('error'):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': result['error'],
|
|
'rate_limit': rate_limit,
|
|
}), 502
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'apps': result['apps'],
|
|
'categories': result['categories'],
|
|
'authors': result['authors'],
|
|
'count': result['count'],
|
|
'cached': result['cached'],
|
|
'rate_limit': rate_limit,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.exception("[Starlark] browse_tronbyte_repository failed")
|
|
return jsonify({'status': 'error', 'message': 'Failed to browse repository', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/starlark/repository/install', methods=['POST'])
|
|
def install_from_tronbyte_repository():
|
|
"""Install an app from the Tronbyte repository."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
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 = _pkg._get_tronbyte_repository_class()
|
|
import tempfile
|
|
|
|
repo = TronbyteRepository(github_token=_starlark_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:
|
|
# Pass filename from metadata (e.g., "analog_clock.star" for analogclock app)
|
|
# Note: manifest uses 'fileName' (camelCase), not 'filename'
|
|
filename = metadata.get('fileName') if metadata else None
|
|
success, error = repo.download_star_file(data['app_id'], Path(temp_path), filename=filename)
|
|
if not success:
|
|
return jsonify({'status': 'error', 'message': f'Failed to download app: {error}'}), 500
|
|
|
|
# Download assets (images, sources, etc.) to a temp directory
|
|
import tempfile
|
|
temp_assets_dir = tempfile.mkdtemp()
|
|
try:
|
|
success_assets, error_assets = repo.download_app_assets(data['app_id'], Path(temp_assets_dir))
|
|
# Asset download is non-critical - log warning but continue if it fails
|
|
if not success_assets:
|
|
logger.warning(f"Failed to download assets for {data['app_id']}: {error_assets}")
|
|
|
|
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
|
|
}
|
|
|
|
starlark_plugin = _pkg._get_starlark_plugin()
|
|
if starlark_plugin:
|
|
success = starlark_plugin.install_app(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir)
|
|
else:
|
|
success = _install_star_file(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir)
|
|
finally:
|
|
# Clean up temp assets directory
|
|
try:
|
|
shutil.rmtree(temp_assets_dir)
|
|
except OSError:
|
|
pass
|
|
|
|
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.exception("[Starlark] install_from_tronbyte_repository failed")
|
|
hint = _ownership_hint(e)
|
|
if hint:
|
|
# `details` is kept deliberately. CodeQL flags it as information
|
|
# exposure, but this package's rule is "if it returns 5xx, it says
|
|
# why" -- enforced by test_no_api_v3_handler_discards_its_exception,
|
|
# whose PRE_EXISTING allowance may shrink and never grow. The
|
|
# Starlark routes are exactly the ones that policy was written for:
|
|
# they answered 500 with no detail for three releases. It stays.
|
|
return jsonify({'status': 'error', 'message': hint,
|
|
'details': describe_exception(e)}), 500
|
|
return jsonify({'status': 'error', 'message': 'Failed to install from repository', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/starlark/repository/categories', methods=['GET'])
|
|
def get_tronbyte_categories():
|
|
"""Get list of available app categories (uses bulk cache)."""
|
|
try:
|
|
TronbyteRepository = _pkg._get_tronbyte_repository_class()
|
|
repo = TronbyteRepository(github_token=_starlark_github_token())
|
|
|
|
result = repo.list_all_apps_cached()
|
|
|
|
if result.get('error'):
|
|
return jsonify({'status': 'error', 'message': result['error']}), 502
|
|
|
|
return jsonify({'status': 'success', 'categories': result['categories']})
|
|
|
|
except Exception as e:
|
|
logger.exception("[Starlark] get_tronbyte_categories failed")
|
|
return jsonify({'status': 'error', 'message': 'Failed to fetch categories', 'details': describe_exception(e)}), 500
|
|
|
|
|
|
@api_v3.route('/starlark/editor/apps', methods=['GET'])
|
|
def list_pixlet_editor_apps():
|
|
"""Apps on disk that the editor can open.
|
|
|
|
Read from the directory rather than the loaded plugin: the editor works on
|
|
files, and the plugin may not be loaded in this process at all.
|
|
"""
|
|
try:
|
|
apps = []
|
|
if _STARLARK_APPS_DIR.is_dir():
|
|
for entry in sorted(_STARLARK_APPS_DIR.iterdir()):
|
|
if not entry.is_dir():
|
|
continue
|
|
star_files = sorted(entry.glob('*.star'))
|
|
name = entry.name
|
|
manifest = entry / 'manifest.json'
|
|
if manifest.is_file():
|
|
try:
|
|
with open(manifest, encoding='utf-8') as handle:
|
|
data = json.load(handle)
|
|
if isinstance(data, dict):
|
|
name = data.get('name') or name
|
|
except (OSError, json.JSONDecodeError):
|
|
pass
|
|
apps.append({
|
|
'id': entry.name,
|
|
'name': name,
|
|
'editable': bool(star_files),
|
|
'has_config': (entry / 'config.json').is_file(),
|
|
})
|
|
return jsonify({'status': 'success', 'data': {
|
|
'apps': apps,
|
|
'apps_dir': str(_STARLARK_APPS_DIR),
|
|
'pixlet_available': _find_pixlet_binary() is not None,
|
|
}})
|
|
except Exception as e:
|
|
logger.exception('Error listing editor apps')
|
|
return jsonify({'status': 'error', 'message': 'Could not list apps',
|
|
'details': describe_exception(e)}), 500
|
|
|
|
@api_v3.route('/starlark/editor/status', methods=['GET'])
|
|
def get_pixlet_editor_status():
|
|
"""Whether a session is running, and how long it has left."""
|
|
try:
|
|
return jsonify({'status': 'success', 'data': _pixlet_editor_status()})
|
|
except Exception as e:
|
|
logger.exception('Error reading editor status')
|
|
return jsonify({'status': 'error', 'message': 'Could not read editor status',
|
|
'details': describe_exception(e)}), 500
|
|
|
|
#: Flask runs threaded in the supported service, so two start requests can each
|
|
#: observe running=False, each launch an editor, and the second state write
|
|
#: replace the first PID -- orphaning a process that holds the display down with
|
|
#: nothing left recording it. The check-launch-write sequence takes this lock.
|
|
_EDITOR_START_LOCK = threading.Lock()
|
|
|
|
|
|
def _terminate_editor_process(pid, wait_s=5):
|
|
"""Signal an editor's process group and wait briefly for it to exit."""
|
|
try:
|
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
os.kill(pid, signal.SIGTERM)
|
|
deadline = _pkg.time.time() + wait_s
|
|
while _pkg.time.time() < deadline and _pixlet_editor_alive(pid):
|
|
_pkg.time.sleep(0.25)
|
|
if _pixlet_editor_alive(pid):
|
|
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
|
|
|
|
|
@api_v3.route('/starlark/editor/start', methods=['POST'])
|
|
def start_pixlet_editor():
|
|
"""Start an editing session for one app."""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
app_id = data.get('app_id')
|
|
|
|
app_dir, err = _validate_starlark_app_path(app_id or '')
|
|
if err or not app_dir:
|
|
return jsonify({'status': 'error', 'message': err or 'Invalid app_id'}), 400
|
|
if not app_dir.is_dir():
|
|
return jsonify({'status': 'error', 'message': f'No such app: {app_id}'}), 404
|
|
if not any(app_dir.glob('*.star')):
|
|
return jsonify({'status': 'error',
|
|
'message': f'{app_id} has no .star file to edit'}), 400
|
|
if not _PIXLET_EDITOR_SCRIPT.is_file():
|
|
return jsonify({'status': 'error', 'message': 'Editor script not found'}), 404
|
|
if _find_pixlet_binary() is None:
|
|
return jsonify({'status': 'error',
|
|
'message': 'Pixlet is not installed - install it first'}), 503
|
|
|
|
with _EDITOR_START_LOCK:
|
|
current = _pixlet_editor_status()
|
|
if current.get('running'):
|
|
return jsonify({'status': 'error',
|
|
'message': f"An editor session for '{current.get('app_id')}' is "
|
|
f"already running; stop it first"}), 409
|
|
|
|
try:
|
|
timeout_s = int(data.get('timeout') or _PIXLET_EDITOR_DEFAULT_TIMEOUT)
|
|
except (TypeError, ValueError):
|
|
return jsonify({'status': 'error', 'message': 'timeout must be a whole number'}), 400
|
|
if not 60 <= timeout_s <= _PIXLET_EDITOR_MAX_TIMEOUT:
|
|
return jsonify({'status': 'error',
|
|
'message': f'timeout must be between 60 and '
|
|
f'{_PIXLET_EDITOR_MAX_TIMEOUT} seconds'}), 400
|
|
try:
|
|
port = int(data.get('port') or _PIXLET_EDITOR_DEFAULT_PORT)
|
|
except (TypeError, ValueError):
|
|
return jsonify({'status': 'error', 'message': 'port must be a whole number'}), 400
|
|
if not 1024 <= port <= 65535:
|
|
return jsonify({'status': 'error', 'message': 'port must be between 1024 and 65535'}), 400
|
|
|
|
env = dict(os.environ)
|
|
env['PIXLET_EDITOR_PORT'] = str(port)
|
|
env['PIXLET_EDITOR_TIMEOUT'] = str(timeout_s)
|
|
# A browser reaching this endpoint is remote by definition, so the
|
|
# session needs to listen on more than loopback to be usable at all --
|
|
# but only as a *default*. An operator who has already set
|
|
# PIXLET_EDITOR_HOST (e.g. to keep it loopback-only even from the web
|
|
# UI) must not have that overridden here.
|
|
env.setdefault('PIXLET_EDITOR_HOST', '0.0.0.0')
|
|
|
|
log_path = Path(tempfile.gettempdir()) / 'ledmatrix_pixlet_editor.log'
|
|
log_handle = open(log_path, 'w', encoding='utf-8') # noqa: SIM115 - owned by the child
|
|
try:
|
|
# start_new_session so the script leads its own process group: the
|
|
# stop route signals the group, which is what lets the EXIT trap run
|
|
# and hand the display back.
|
|
process = subprocess.Popen( # nosec B603 - fixed script path, validated app_id
|
|
['/bin/bash', str(_PIXLET_EDITOR_SCRIPT), app_dir.name],
|
|
cwd=str(PROJECT_ROOT), env=env,
|
|
stdout=log_handle, stderr=subprocess.STDOUT,
|
|
start_new_session=True)
|
|
finally:
|
|
log_handle.close()
|
|
|
|
now = _pkg.time.time()
|
|
state = {'pid': process.pid, 'app_id': app_dir.name, 'port': port,
|
|
'timeout': timeout_s, 'started_at': now, 'deadline': now + timeout_s,
|
|
'host': env['PIXLET_EDITOR_HOST'], 'log': str(log_path)}
|
|
try:
|
|
with open(_PIXLET_EDITOR_STATE, 'w', encoding='utf-8') as handle:
|
|
json.dump(state, handle)
|
|
except OSError as err:
|
|
# The process is up but nothing records its PID: status and stop
|
|
# would both report no session while the display stays down until
|
|
# the timeout expires. Take the editor with us instead.
|
|
# exc_info: this path answers 500, and the guard in
|
|
# test_web_error_detail.py requires the traceback to reach the log
|
|
# as well as the sanitized detail reaching the caller.
|
|
logger.error('Started an editor session but could not record it: %s',
|
|
err, exc_info=True)
|
|
_terminate_editor_process(process.pid)
|
|
return jsonify({'status': 'error',
|
|
'message': 'Could not record the editor session; '
|
|
'the editor was stopped.',
|
|
'details': describe_exception(err)}), 500
|
|
|
|
logger.info('Pixlet editor started for %s on port %s (pid %s, %ss limit)',
|
|
app_dir.name, port, process.pid, timeout_s)
|
|
return jsonify({'status': 'success',
|
|
'message': f"Editing '{app_dir.name}'. The display is stopped until "
|
|
f"the session ends.",
|
|
'data': _pixlet_editor_status()})
|
|
except Exception as e:
|
|
logger.exception('Error starting the pixlet editor')
|
|
return jsonify({'status': 'error', 'message': 'Could not start the editor',
|
|
'details': describe_exception(e)}), 500
|
|
|
|
@api_v3.route('/starlark/editor/stop', methods=['POST'])
|
|
def stop_pixlet_editor():
|
|
"""End the running session and give the display back."""
|
|
try:
|
|
state = _read_pixlet_editor_state()
|
|
if not state or not _pixlet_editor_alive(state.get('pid')):
|
|
_clear_pixlet_editor_state()
|
|
return jsonify({'status': 'success', 'message': 'No editor session was running.',
|
|
'data': {'running': False}})
|
|
|
|
pid = int(state['pid'])
|
|
# SIGTERM the group, not the pid: bash forwards nothing to `timeout` and
|
|
# its child on its own, and the trap needs to run to restart the display.
|
|
try:
|
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
except (ProcessLookupError, PermissionError, OSError) as err:
|
|
logger.debug('Could not signal the editor process group: %s', err)
|
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
os.kill(pid, signal.SIGTERM)
|
|
|
|
# Give the trap a moment to stop pixlet and restart ledmatrix.
|
|
deadline = _pkg.time.time() + 10
|
|
while _pkg.time.time() < deadline and _pixlet_editor_alive(pid):
|
|
_pkg.time.sleep(0.25)
|
|
|
|
escalated = False
|
|
if _pixlet_editor_alive(pid):
|
|
logger.warning('Editor session %s ignored SIGTERM; sending SIGKILL.', pid)
|
|
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
|
escalated = True
|
|
|
|
_clear_pixlet_editor_state()
|
|
|
|
if escalated:
|
|
# SIGKILL gives the script's EXIT trap no chance to run, so nothing
|
|
# has handed the display back. Reporting "the display is restarting"
|
|
# here was simply untrue: restart it, and if that fails say so
|
|
# rather than leave the panel dark behind a success response.
|
|
result = _run_systemctl_command(
|
|
['sudo', 'systemctl', 'start', 'ledmatrix.service'])
|
|
if result.get('returncode') != 0:
|
|
logger.error('Display restart after SIGKILL failed: %s',
|
|
(result.get('stderr') or '').strip())
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Editor force-stopped, but the display could not be '
|
|
'restarted automatically - start it manually.',
|
|
'details': (result.get('stderr') or '').strip(),
|
|
'data': {'running': False}}), 500
|
|
return jsonify({'status': 'success',
|
|
'message': 'Editor force-stopped; the display has been '
|
|
'restarted.',
|
|
'data': {'running': False}})
|
|
|
|
return jsonify({'status': 'success',
|
|
'message': 'Editor stopped; the display is restarting.',
|
|
'data': {'running': False}})
|
|
except Exception as e:
|
|
logger.exception('Error stopping the pixlet editor')
|
|
return jsonify({'status': 'error', 'message': 'Could not stop the editor',
|
|
'details': describe_exception(e)}), 500
|