diff --git a/test/web_interface/test_starlark_pixlet_routes.py b/test/web_interface/test_starlark_pixlet_routes.py index fa2d6878..12697975 100644 --- a/test/web_interface/test_starlark_pixlet_routes.py +++ b/test/web_interface/test_starlark_pixlet_routes.py @@ -199,3 +199,74 @@ class TestNoStarlarkRouteIsMissing: except NotFound: missing.append(u) 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:` + 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'] diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 9dfd7f8e..df7fe1a3 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -2813,6 +2813,7 @@ def get_installed_plugins(): with ThreadPoolExecutor(max_workers=8) as executor: results = list(executor.map(_build_plugin_entry, all_plugin_info)) plugins = [r for r in results if r is not None] + plugins.extend(_starlark_virtual_plugins()) return jsonify({'status': 'success', 'data': {'plugins': plugins}}) except Exception as e: @@ -3119,6 +3120,13 @@ def toggle_plugin(): current_enabled = config.get(plugin_id, {}).get('enabled', False) 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) if plugin_id not in api_v3.plugin_manager.plugin_manifests: return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 @@ -9667,3 +9675,79 @@ def get_tronbyte_categories(): except Exception as e: logger.exception("[Starlark] get_tronbyte_categories failed") 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})