mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
fix(starlark): show installed apps with the other plugins, and let them toggle
An app installed from the store appeared nowhere and could not be enabled or disabled. Same cause as the 404s: #253 surfaced installed apps in /plugins/installed as `starlark:<app_id>` entries and routed `starlark:` toggles to the Starlark manifest, and #330 removed both along with the routes. Without the first, the app store installs successfully into a list nothing renders. Without the second, toggling one falls through to the plugin_manager lookup and answers "Plugin not found" -- a Starlark app is an entry in starlark-apps' own manifest, not a plugin in that sense. Restored as two named helpers rather than the original inline blocks: _starlark_virtual_plugins() reads the loaded plugin when there is one and the on-disk manifest otherwise, so the list is right before starlark-apps loads _toggle_starlark_app() updates through the plugin's own _update_manifest_safe when loaded, or the manifest directly when not Two changes on the original. The toggle now runs app_id through _validate_and_sanitize_app_id first -- it reaches a filesystem manifest and had no validation where the other Starlark routes all have it. And _starlark_virtual_plugins swallows its own failures: the entries are appended to the real plugin list, and a broken Starlark manifest should cost the Starlark rows, not empty the plugins page. 6 new tests covering listing, the fields the UI keys on, toggle persistence, the unknown-app 404, traversal rejection, and that a Starlark failure leaves the rest of the list intact. Full core suite: 3979 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
This commit is contained in:
co-authored by
Claude Opus 5
parent
2538690435
commit
2a92f1ab3b
@@ -199,3 +199,74 @@ class TestNoStarlarkRouteIsMissing:
|
|||||||
except NotFound:
|
except NotFound:
|
||||||
missing.append(u)
|
missing.append(u)
|
||||||
assert not missing, f"the frontend calls these and they are not registered: {missing}"
|
assert not missing, f"the frontend calls these and they are not registered: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstalledAppsAppearWithTheOtherPlugins:
|
||||||
|
"""An installed .star app must be manageable like any other plugin.
|
||||||
|
|
||||||
|
#253 surfaced installed apps in /plugins/installed as `starlark:<app_id>`
|
||||||
|
entries, so they could be seen and enabled/disabled from the same list as
|
||||||
|
everything else, and routed `starlark:` toggles to the Starlark manifest.
|
||||||
|
#330 removed both. The result was an app that installs successfully, then
|
||||||
|
appears nowhere and cannot be turned on or off.
|
||||||
|
"""
|
||||||
|
|
||||||
|
APPS = {'apps': {'quoteoftheday': {'name': 'A Quote A Day', 'enabled': True}}}
|
||||||
|
|
||||||
|
def test_an_installed_app_is_listed(self, client):
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=self.APPS):
|
||||||
|
resp = client.get('/api/v3/plugins/installed')
|
||||||
|
ids = [p['id'] for p in resp.get_json()['data']['plugins']]
|
||||||
|
assert 'starlark:quoteoftheday' in ids, \
|
||||||
|
"an installed Starlark app does not appear among the plugins"
|
||||||
|
|
||||||
|
def test_the_entry_carries_what_the_ui_needs(self, client):
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=self.APPS):
|
||||||
|
resp = client.get('/api/v3/plugins/installed')
|
||||||
|
entry = next(p for p in resp.get_json()['data']['plugins']
|
||||||
|
if p['id'] == 'starlark:quoteoftheday')
|
||||||
|
assert entry['name'] == 'A Quote A Day'
|
||||||
|
assert entry['enabled'] is True
|
||||||
|
assert entry['is_starlark_app'] is True, "the UI keys its Starlark handling off this"
|
||||||
|
assert entry['category'] == 'Starlark App'
|
||||||
|
|
||||||
|
def test_a_starlark_failure_does_not_empty_the_plugin_list(self, client):
|
||||||
|
# The virtual entries are appended to the real ones; a broken manifest
|
||||||
|
# must cost the Starlark rows, not everybody else's.
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
|
||||||
|
side_effect=RuntimeError('boom')):
|
||||||
|
resp = client.get('/api/v3/plugins/installed')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()['status'] == 'success'
|
||||||
|
|
||||||
|
def test_toggling_an_app_does_not_report_plugin_not_found(self, client):
|
||||||
|
written = {}
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
||||||
|
return_value={'apps': {'quoteoftheday': {'enabled': True}}}), \
|
||||||
|
patch('web_interface.blueprints.api_v3._write_starlark_manifest',
|
||||||
|
side_effect=lambda m: written.update(m) or True):
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:quoteoftheday', 'enabled': False})
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body['status'] == 'success', body
|
||||||
|
assert body['enabled'] is False
|
||||||
|
assert written['apps']['quoteoftheday']['enabled'] is False, \
|
||||||
|
"the manifest was not actually updated"
|
||||||
|
|
||||||
|
def test_toggling_an_unknown_app_says_so(self, client):
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
||||||
|
return_value={'apps': {}}):
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:nope', 'enabled': True})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert 'nope' in resp.get_json()['message']
|
||||||
|
|
||||||
|
def test_a_traversal_app_id_is_rejected_before_touching_the_manifest(self, client):
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:../../etc/passwd', 'enabled': True})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert 'invalid characters' in resp.get_json()['message']
|
||||||
|
|||||||
@@ -2813,6 +2813,7 @@ def get_installed_plugins():
|
|||||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||||
results = list(executor.map(_build_plugin_entry, all_plugin_info))
|
results = list(executor.map(_build_plugin_entry, all_plugin_info))
|
||||||
plugins = [r for r in results if r is not None]
|
plugins = [r for r in results if r is not None]
|
||||||
|
plugins.extend(_starlark_virtual_plugins())
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'data': {'plugins': plugins}})
|
return jsonify({'status': 'success', 'data': {'plugins': plugins}})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -3119,6 +3120,13 @@ def toggle_plugin():
|
|||||||
current_enabled = config.get(plugin_id, {}).get('enabled', False)
|
current_enabled = config.get(plugin_id, {}).get('enabled', False)
|
||||||
enabled = not current_enabled
|
enabled = not current_enabled
|
||||||
|
|
||||||
|
# A Starlark app is not a plugin in plugin_manager's sense -- it is an
|
||||||
|
# entry in starlark-apps' own manifest -- so its enable/disable is
|
||||||
|
# handled here rather than falling through to the check below, which
|
||||||
|
# would answer "Plugin not found".
|
||||||
|
if plugin_id.startswith('starlark:'):
|
||||||
|
return _toggle_starlark_app(plugin_id[len('starlark:'):], enabled)
|
||||||
|
|
||||||
# Check if plugin exists in manifests (discovered but may not be loaded)
|
# Check if plugin exists in manifests (discovered but may not be loaded)
|
||||||
if plugin_id not in api_v3.plugin_manager.plugin_manifests:
|
if plugin_id not in api_v3.plugin_manager.plugin_manifests:
|
||||||
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||||
@@ -9667,3 +9675,79 @@ def get_tronbyte_categories():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] get_tronbyte_categories failed")
|
logger.exception("[Starlark] get_tronbyte_categories failed")
|
||||||
return jsonify({'status': 'error', 'message': 'Failed to fetch categories'}), 500
|
return jsonify({'status': 'error', 'message': 'Failed to fetch categories'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def _starlark_virtual_plugins() -> list:
|
||||||
|
"""Installed Starlark apps, shaped like plugin entries.
|
||||||
|
|
||||||
|
#253 surfaced these alongside real plugins so an installed .star app can
|
||||||
|
be seen, enabled and disabled from the same list as everything else; #330
|
||||||
|
dropped it with the rest of the Starlark code, which is why an app
|
||||||
|
installs successfully and then appears nowhere.
|
||||||
|
|
||||||
|
Reads the loaded plugin when there is one and the on-disk manifest
|
||||||
|
otherwise, so the list is right before starlark-apps has been loaded too.
|
||||||
|
"""
|
||||||
|
entries = []
|
||||||
|
base = {
|
||||||
|
'version': 'starlark', 'category': 'Starlark App', 'tags': ['starlark'],
|
||||||
|
'verified': False, '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,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
plugin = _get_starlark_plugin()
|
||||||
|
if plugin is not None and hasattr(plugin, 'apps'):
|
||||||
|
for app_id, app in plugin.apps.items():
|
||||||
|
m = getattr(app, 'manifest', {}) or {}
|
||||||
|
entries.append({**base,
|
||||||
|
'id': f'starlark:{app_id}',
|
||||||
|
'name': m.get('name', app_id),
|
||||||
|
'author': m.get('author', 'Tronbyte Community'),
|
||||||
|
'description': m.get('summary', 'Starlark app'),
|
||||||
|
'enabled': app.is_enabled(),
|
||||||
|
'loaded': True})
|
||||||
|
return entries
|
||||||
|
|
||||||
|
for app_id, data in (_read_starlark_manifest().get('apps', {}) or {}).items():
|
||||||
|
entries.append({**base,
|
||||||
|
'id': f'starlark:{app_id}',
|
||||||
|
'name': data.get('name', app_id),
|
||||||
|
'author': data.get('author', 'Tronbyte Community'),
|
||||||
|
'description': data.get('summary', 'Starlark app'),
|
||||||
|
'enabled': data.get('enabled', True),
|
||||||
|
'loaded': False})
|
||||||
|
except Exception:
|
||||||
|
# Never let a Starlark problem empty the whole plugins list.
|
||||||
|
logger.exception('Could not build Starlark virtual plugin entries')
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _toggle_starlark_app(app_id: str, enabled: bool):
|
||||||
|
"""Enable or disable one Starlark app, loaded or not."""
|
||||||
|
safe_id, err = _validate_and_sanitize_app_id(app_id)
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Invalid app_id: {err}'}), 400
|
||||||
|
|
||||||
|
plugin = _get_starlark_plugin()
|
||||||
|
if plugin is not None and safe_id in getattr(plugin, 'apps', {}):
|
||||||
|
plugin.apps[safe_id].manifest['enabled'] = enabled
|
||||||
|
|
||||||
|
def _update(manifest):
|
||||||
|
manifest['apps'][safe_id]['enabled'] = enabled
|
||||||
|
|
||||||
|
plugin._update_manifest_safe(_update)
|
||||||
|
else:
|
||||||
|
manifest = _read_starlark_manifest()
|
||||||
|
app_data = manifest.get('apps', {}).get(safe_id)
|
||||||
|
if not app_data:
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': f'Starlark app not found: {safe_id}'}), 404
|
||||||
|
app_data['enabled'] = enabled
|
||||||
|
if not _write_starlark_manifest(manifest):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500
|
||||||
|
|
||||||
|
return jsonify({'status': 'success',
|
||||||
|
'message': f"Starlark app {'enabled' if enabled else 'disabled'}",
|
||||||
|
'enabled': enabled})
|
||||||
|
|||||||
Reference in New Issue
Block a user