mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* refactor(api-v3): split the 10,469-line blueprint into a package web_interface/blueprints/api_v3.py held 111 routes, 56 helpers and 181 functions in one module -- 9% of the core by line count and three times the next largest file. It becomes a package of nine route modules grouped by path segment, plus __init__.py for the shared imports, constants, Blueprint and helpers. Every route module decorates the SAME api_v3 Blueprint object, so endpoint names stay api_v3.<function>, the URL map is unchanged and app.py is untouched. Verified: 111 routes before, 111 after, byte-identical rules, endpoints and methods, and every endpoint still on the one blueprint. plugins 3,867 config 1,178 starlark 692 system 619 fonts 452 misc 398 wifi 361 display 326 backup 212 __init__ 1,787 (imports, constants, Blueprint, 56 helpers) Two things the URL-map check could not catch, both found by running the suite: 1. PROJECT_ROOT = Path(__file__).parent.parent.parent. Moving the code one directory deeper made that resolve to web_interface/ instead of the project root. Nothing failed at import; it surfaced as ~110 tests failing with 404s and "installation script not found", because every path built from it was one level too shallow. Now parents[3], and test_api_v3_url_map.py asserts PROJECT_ROOT/run.py exists so the next move cannot repeat it. 2. Module-attribute patching. Tests do monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", ...) and a route module that binds such a name by value never sees the patch. The shared code therefore stays in __init__.py rather than moving to a _common submodule -- it has to live on the module the tests patch -- and the eleven names tests patch are read back through the package (_pkg.X) instead of bound by value. Those eleven were found by AST-scanning every setattr in the test tree, not by guessing; "time" is among them, used to drive a fake clock through the second-resolution credential-backup filenames. Test changes are confined to what genuinely moved: patch targets that now name the owning route module, imports of helpers, and six tests that scan the api_v3 source as a file and now read the package directory. Full suite: 4,278 passed, 68 skipped, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(api-v3): address CodeRabbit findings from the blueprint-split review Fixes to the api_v3 package split (PR #553), one per finding verified against the actual code: - __init__.py: _redact_credentials only blanked scalar values under a credential-named key; a bare list of secrets under such a key (e.g. tokens: ["a", "b"]) passed through untouched, since the list branch recursed with no memory that its key looked like a credential. Nested dicts still walk normally (a documented, tested behaviour -- a container like secrets: {api_key: ..., note: ...} is a section name, not a value to blank outright), but any value reached under a credential-shaped key is now actually blanked. - __init__.py: the OAuth helper script's raw stderr/stdout went to logger.error unredacted (CWE-532) right next to a comment claiming this was deliberate; the HTTP response already used the existing redact_text helper. Routed the log line through the same helper. - __init__.py / starlark.py: the standalone Starlark manifest fallback (used when the plugin instance isn't loaded) read-modified-wrote manifest.json with no lock, unlike StarlarkAppsPlugin._update_manifest_safe (plugin-repos/starlark-apps/manager.py), which already holds an flock for the same file when the plugin is loaded. Added _starlark_manifest_lock, mirroring that pattern, and wrapped every standalone read-modify-write call site in it. The app-config update route also wrote config.json and the manifest as two separate, non-transactional writes (a second, distinct finding at the same call site); config.json is now rolled back if the manifest write that follows it fails. - backup.py: restore options used bare bool() on values from the request, so {"restore_secrets": "false"} restored secrets anyway (bool("false") is True). Switched to the existing _coerce_to_bool helper already used for this exact purpose elsewhere in the package. - config.py: an automated import-rewrite mangled four user-facing validation strings and their neighbouring comments -- "Invalid start time" had become "Invalid start _pkg.time" (and likewise for "end time") in both the schedule and dim-schedule per-day validation paths. - display.py: `import _pkg.time as time_module` -- _pkg is a local alias for the package, not a real importable module, so this raised ModuleNotFoundError whenever a caller restarted an already-running display service via /display/on-demand/start, after the on-demand request was already written to cache. Fixed to `import time`. Audited the rest of the package for the same `_pkg.<module>` import mistake; every other `_pkg.` reference is a legitimate attribute read-through (`_pkg.time.time()`, `_pkg._get_starlark_plugin()`, ...), not a broken import statement. - fonts.py: validate_file_upload's max_size_mb parameter is silently unused by that helper (it only checks filename/extension) -- the font upload route saved arbitrarily large files as a result. Added the same seek-and-check pattern already used for the sibling .star upload. - wifi.py: two ad hoc, inconsistent bool coercions. POST /wifi/ap/auto-enable used bare bool(), so a JSON string "false" enabled it. POST /wifi/radio's enabled/force parsing recognized real bool and some strings but not int 1/0 (1 is True is False in Python). Factored one small _parse_bool_ish helper local to this file and used it at all three sites. Not changed: the "unknown/misspelled restore option keys default to True" half of the backup.py finding -- the file's own comment documents that a missing key deliberately means "restore everything," matching the already-existing JSON-parse-failure guard a few lines above it; only the bool-coercion defect was a real bug. Added or extended regression tests for every fix, following each area's existing test conventions. Full suite: 4328 passed, 62 skipped, 2 failed on both this branch and origin/main (missing tzdata package breaks two timezone-alias tests in test_onboarding_checklist.py, unrelated to this change) -- no new failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3bPMESe2TfrGvbs1ef9c5 * fix(api-v3): reject unknown restore option keys CodeRabbit's review of the blueprint split (#553) asked that POST /backup/restore reject option keys outside RestoreOptions' known set. The follow-up commit fixed the bool("false")-is-True bug with _coerce_to_bool but never added the key check: a typo'd or renamed key (e.g. "restoreSecrets") is silently ignored by opts_dict.get(key, True), so the flag stays at its True default and secrets get restored despite the caller's request saying otherwise -- with no indication anything was wrong. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vmcwf5vMgYqdt8bJTZtiwb * fix(api-v3): address CodeRabbit findings on the blueprint split - _redact_credentials: blank scalar descendants of objects reached through a credential-owned list (e.g. tokens: [{"value": "secret"}]) regardless of field name -- the existing name-based walk only protected direct dict values under a credential key, not list items. - wifi.py: reject enabled/force/auto_enable_ap_mode values _parse_bool_ish can't recognize (400) instead of silently treating them as False, which could disable Wi-Fi or the radio itself. - Starlark manifest locking: lock a stable manifest.json.lock sidecar instead of manifest.json itself, in both the standalone route path (_starlark_manifest_lock) and the plugin path (StarlarkAppsPlugin._save_manifest / _update_manifest_safe). manifest.json is replaced by an atomic rename on every write, which swaps in a fresh inode; a lock held on the old inode does not exclude a second locker that opens the path afresh right after the rename and gets the new inode, so two writers could race despite each holding "a lock". A sidecar that no write ever touches always resolves to the same inode for every locker. Skipped as stale: the "serialize the complete manifest read-modify-write" finding at api_v3/__init__.py -- every standalone handler that calls _write_starlark_manifest is already wrapped in _starlark_manifest_lock() on this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(api-v3): re-check reconciliation findings by the reconciler's own rules Both CodeRabbit findings on the merge commit, verified against the code first. Major, plugins.py: the stale-findings filter derived its own notion of "in config" and "on disk", and both were looser than the reconciliation module's. set(load_config()) also contains system keys, the secrets-file keys load_config() merges in, and non-dict values; and any directory holding a manifest.json counted as installed even when that manifest does not parse. Either looseness clears a finding that is still true -- and a secrets key read as a plugin is the precise bug the filter exists to stop reporting, so reintroducing that asymmetry while re-checking was the wrong way round. The two extractions now live in state_reconciliation.py as config_plugin_ids() and disk_plugin_ids(), with ignored_config_keys() and secrets_top_level_keys() alongside. _get_config_state() and _get_disk_state() use them too, so there is one definition rather than two that can drift. _get_disk_state() re-reads each manifest for version/name after taking membership from the shared extractor; that costs one extra small read per plugin on a path that runs once per boot. Minor, the new test: the fixture assigned api_v3.config_manager and api_v3.plugin_manager directly. Those live on a module-level blueprint singleton, so the mocks leaked into every later test that imports api_v3 -- pointing at a tmp_path already deleted. Both now go through monkeypatch.setattr, which restores them. This is the same pollution class that made an earlier test in this session break seven unrelated ones, so it is worth getting right. Five cases added for the parity itself: a secrets key, a system key and a non-dict value must not clear an "installed but missing from config" finding, and neither an unparseable manifest nor a .standalone-backup- directory may count as installed. All five fail against the looser version. Linux CI on the preceding commit: Core unit tests, plugin harness, CodeQL and CodeRabbit all pass. Codacy reads action_required on every commit of this branch including the first, so it is pre-existing and not from this work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
713 lines
33 KiB
Python
713 lines
33 KiB
Python
"""Starlark / Tronbyte app management routes.
|
|
|
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
|
endpoint names are unchanged by living here.
|
|
"""
|
|
from web_interface.blueprints.api_v3 import (
|
|
PROJECT_ROOT, Path, _find_pixlet_binary, _install_star_file, _standalone_render_starlark_app,
|
|
_starlark_github_token, _starlark_manifest_lock, _validate_and_sanitize_app_id,
|
|
_validate_starlark_app_path, _validate_timing_value, api_v3, describe_exception, json, jsonify,
|
|
logger, os, request, shutil, subprocess, tempfile,
|
|
)
|
|
import web_interface.blueprints.api_v3 as _pkg
|
|
# 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.
|
|
|
|
|
|
@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': '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.
|
|
import shutil
|
|
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)
|
|
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)
|
|
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 {}
|
|
|
|
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
|
|
enabled = data.get('enabled')
|
|
if enabled is None:
|
|
enabled = not app.is_enabled()
|
|
app.manifest['enabled'] = enabled
|
|
# Use safe manifest update to prevent race conditions
|
|
def update_fn(manifest):
|
|
manifest['apps'][app_id]['enabled'] = enabled
|
|
starlark_plugin._update_manifest_safe(update_fn)
|
|
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
|
|
|
|
# Standalone: update manifest directly
|
|
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
|
|
|
|
enabled = data.get('enabled')
|
|
if enabled is None:
|
|
enabled = not app_data.get('enabled', True)
|
|
app_data['enabled'] = enabled
|
|
if _pkg._write_starlark_manifest(manifest):
|
|
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
|
|
else:
|
|
return jsonify({'status': 'error', 'message': 'Failed to save'}), 500
|
|
|
|
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
|
|
import shutil
|
|
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")
|
|
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
|