diff --git a/DOCS/embedded/app-store.md b/DOCS/embedded/app-store.md index 445c585..824a50c 100644 --- a/DOCS/embedded/app-store.md +++ b/DOCS/embedded/app-store.md @@ -8,20 +8,42 @@ This guide outlines the “SecuBox Apps” registry format and the `secubox-app` --- -## Manifest Layout (`plugins//manifest.json`) +## Manifest Layout (`plugins/catalog/.json`) -Each plugin folder contains a `manifest.json`. Example (Zigbee2MQTT): +Each app now ships a normalized JSON manifest under `plugins/catalog/.json` (legacy `plugins//manifest.json` entries remain for backward compatibility). Example (Zigbee2MQTT): ```json { "id": "zigbee2mqtt", "name": "Zigbee2MQTT", - "type": "docker", - "description": "Dockerized Zigbee gateway", + "category": "home-automation", + "runtime": "docker", + "maturity": "stable", + "description": "Dockerized Zigbee gateway bridging Zigbee coordinators with MQTT brokers.", + "source": { + "homepage": "https://www.zigbee2mqtt.io/", + "github": "https://github.com/gkerma/secubox-openwrt/tree/main/secubox-app-zigbee2mqtt" + }, "packages": ["secubox-app-zigbee2mqtt", "luci-app-zigbee2mqtt"], + "capabilities": ["zigbee-gateway", "mqtt", "docker-runner"], + "requirements": { + "arch": ["arm64"], + "min_ram_mb": 256, + "min_storage_mb": 512 + }, + "hardware": { "usb": true, "serial": true }, + "network": { + "inbound_ports": [8080], + "protocols": ["http", "mqtt"], + "outbound_only": false + }, + "privileges": { + "needs_usb": true, + "needs_serial": true, + "needs_net_admin": false + }, "ports": [{ "name": "frontend", "protocol": "http", "port": 8080 }], "volumes": ["/srv/zigbee2mqtt"], - "network": { "default_mode": "lan", "dmz_supported": true }, "wizard": { "uci": { "config": "zigbee2mqtt", "section": "main" }, "fields": [ @@ -33,7 +55,7 @@ Each plugin folder contains a `manifest.json`. Example (Zigbee2MQTT): { "id": "frontend_port", "label": "Frontend Port", "type": "number", "uci_option": "frontend_port" } ] }, - "profiles": ["home", "lab"], + "profiles": { "recommended": ["home", "lab", "iot"] }, "actions": { "install": "zigbee2mqttctl install", "check": "zigbee2mqttctl check", @@ -49,17 +71,22 @@ Each plugin folder contains a `manifest.json`. Example (Zigbee2MQTT): |-----|---------| | `id` | Unique identifier used by the CLI (`secubox-app install `). | | `name` / `description` | Display metadata. | -| `type` | `docker`, `lxc`, or `native`. | +| `category` | One of: home-automation, networking, security, media, monitoring, storage, development, system, iot, radio, misc. | +| `runtime` | `docker`, `lxc`, `native`, or `hybrid`. | | `packages` | List of OpenWrt packages to install/remove. | +| `requirements.arch` | Architectures supported by the app/runtime. | +| `requirements.min_ram_mb` / `requirements.min_storage_mb` | Conservative resource guidance for UI filters. | | `actions.install/update/check/status` | Optional shell commands executed after opkg operations. | **Optional keys** - `ports`: Document exposed services for the App Store UI. - `volumes`: Persistent directories (e.g., `/srv/zigbee2mqtt`). -- `network`: Defaults + whether DMZ mode is supported. +- `network`: Connection hints (protocols, inbound ports, outbound-only flag). +- `hardware` / `privileges`: USB/serial/net_admin hints for wizards. - `wizard`: UCI target plus the declarative field list consumed by the LuCI wizard. -- `profiles`: Tags to pre-load when applying OS-like profiles. +- `profiles`: Tags to pre-load when applying OS-like profiles (e.g., `profiles.recommended` array). +- `capabilities`, `maturity`, `source`, `update.strategy`: Additional metadata for filter chips and CLI instructions. --- @@ -83,6 +110,9 @@ secubox-app status zigbee2mqtt # Update or remove secubox-app update zigbee2mqtt secubox-app remove zigbee2mqtt + +# Validate manifests (schema + requirements) +secubox-app validate ``` Environment variables: @@ -104,6 +134,9 @@ The CLI relies on `opkg` and `jsonfilter`, so run it on the router (or within th All three packages declare their dependencies (Docker, vhost manager, etc.) so `secubox-app install ` only has to orchestrate actions, not guess at required feeds. +- **Manifest QA**: run `secubox-app validate` before commits/releases to catch missing IDs, runtimes, or packages. +- **Specs refresh**: `python scripts/refresh-manifest-specs.py` re-applies shared architecture/min-spec heuristics so individual JSON files stay in sync. + --- ## Future Integration diff --git a/luci-app-secubox/htdocs/luci-static/resources/view/secubox/appstore.js b/luci-app-secubox/htdocs/luci-static/resources/view/secubox/appstore.js new file mode 100644 index 0000000..2131d83 --- /dev/null +++ b/luci-app-secubox/htdocs/luci-static/resources/view/secubox/appstore.js @@ -0,0 +1,456 @@ +'use strict'; +'require view'; +'require ui'; +'require dom'; +'require secubox/api as API'; +'require secubox/nav as SecuNav'; + +var RUNTIME_FILTERS = [ + { id: 'all', label: _('All runtimes') }, + { id: 'docker', label: _('Docker') }, + { id: 'lxc', label: _('LXC') }, + { id: 'native', label: _('Native') } +]; + +var STATE_FILTERS = [ + { id: 'all', label: _('All states') }, + { id: 'installed', label: _('Installed') }, + { id: 'available', label: _('Available') } +]; + +var RUNTIME_ICONS = { + docker: '🐳', + lxc: '📦', + native: '⚙️', + hybrid: '🧬' +}; + +return view.extend({ + load: function() { + return Promise.all([ + API.listApps() + ]); + }, + + render: function(payload) { + this.apps = (payload[0] && payload[0].apps) || []; + this.searchQuery = ''; + this.runtimeFilter = 'all'; + this.stateFilter = 'all'; + this.filterButtons = { runtime: {}, state: {} }; + + this.root = E('div', { 'class': 'secubox-appstore-page' }, [ + E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox/common.css') }), + SecuNav.renderTabs('appstore'), + this.renderHeader(), + this.renderStats(), + this.renderFilterBar(), + this.renderAppGrid() + ]); + + this.updateStats(); + this.updateAppGrid(); + return this.root; + }, + + renderHeader: function() { + return E('div', { 'class': 'sh-page-header sh-page-header-lite' }, [ + E('div', {}, [ + E('h2', { 'class': 'sh-page-title' }, [ + E('span', { 'class': 'sh-page-title-icon' }, '🛒'), + _('SecuBox App Store') + ]), + E('p', { 'class': 'sh-page-subtitle' }, + _('Browse manifest-driven apps, launch guided wizards, and copy CLI commands from the SecuBox App Store.')) + ]) + ]); + }, + + renderStats: function() { + this.statsNodes = { + total: E('div', { 'class': 'sb-stat-value' }, '0'), + installed: E('div', { 'class': 'sb-stat-value' }, '0'), + docker: E('div', { 'class': 'sb-stat-value' }, '0'), + lxc: E('div', { 'class': 'sb-stat-value' }, '0') + }; + + return E('div', { 'class': 'sb-stats-row' }, [ + this.renderStatCard('📦', _('Total apps'), this.statsNodes.total, _('Manifest entries detected')), + this.renderStatCard('✅', _('Installed'), this.statsNodes.installed, _('Apps currently deployed')), + this.renderStatCard('🐳', _('Docker'), this.statsNodes.docker, _('Containerized services')), + this.renderStatCard('📦', _('LXC'), this.statsNodes.lxc, _('Lightweight containers')) + ]); + }, + + renderStatCard: function(icon, title, valueEl, subtitle) { + return E('div', { 'class': 'sb-stat-card' }, [ + E('div', { 'class': 'sb-stat-icon' }, icon), + E('div', { 'class': 'sb-stat-label' }, title), + valueEl, + E('div', { 'class': 'sb-stat-sub' }, subtitle) + ]); + }, + + renderFilterBar: function() { + var self = this; + this.searchInput = E('input', { + 'class': 'sb-wizard-input', + 'type': 'search', + 'placeholder': _('Search apps…') + }); + this.searchInput.addEventListener('input', function(ev) { + self.searchQuery = (ev.target.value || '').trim().toLowerCase(); + self.updateAppGrid(); + }); + + return E('div', { 'class': 'secubox-appstore-filters' }, [ + E('div', { 'class': 'sb-filter-group' }, [ + E('div', { 'class': 'sb-filter-label' }, _('Type')), + E('div', { 'class': 'sb-filter-pills' }, RUNTIME_FILTERS.map(function(filter) { + var pill = E('button', { + 'class': 'sb-filter-pill' + (filter.id === self.runtimeFilter ? ' active' : ''), + 'click': self.handleFilterClick.bind(self, 'runtime', filter.id) + }, filter.label); + self.filterButtons.runtime[filter.id] = pill; + return pill; + })) + ]), + E('div', { 'class': 'sb-filter-group' }, [ + E('div', { 'class': 'sb-filter-label' }, _('State')), + E('div', { 'class': 'sb-filter-pills' }, STATE_FILTERS.map(function(filter) { + var pill = E('button', { + 'class': 'sb-filter-pill' + (filter.id === self.stateFilter ? ' active' : ''), + 'click': self.handleFilterClick.bind(self, 'state', filter.id) + }, filter.label); + self.filterButtons.state[filter.id] = pill; + return pill; + })) + ]), + E('div', { 'class': 'sb-filter-search' }, [ + E('span', { 'class': 'sb-filter-search-icon' }, '🔍'), + this.searchInput + ]) + ]); + }, + + handleFilterClick: function(group, value, ev) { + ev.preventDefault(); + if (group === 'runtime') + this.runtimeFilter = value; + else + this.stateFilter = value; + + this.updateFilterButtons(group); + this.updateAppGrid(); + }, + + updateFilterButtons: function(group) { + var buttons = this.filterButtons[group] || {}; + Object.keys(buttons).forEach(function(key) { + var el = buttons[key]; + if (!el) + return; + if ((group === 'runtime' && key === this.runtimeFilter) || + (group === 'state' && key === this.stateFilter)) + el.classList.add('active'); + else + el.classList.remove('active'); + }, this); + }, + + renderAppGrid: function() { + this.appGrid = E('div', { 'class': 'sb-app-grid secubox-appstore-grid' }); + return this.appGrid; + }, + + updateStats: function() { + var total = this.apps.length; + var installed = this.apps.filter(function(app) { return app.state === 'installed'; }).length; + var docker = this.apps.filter(function(app) { return (app.runtime || app.type || '') === 'docker'; }).length; + var lxc = this.apps.filter(function(app) { return (app.runtime || app.type || '') === 'lxc'; }).length; + + if (this.statsNodes) { + this.statsNodes.total.textContent = total.toString(); + this.statsNodes.installed.textContent = installed.toString(); + this.statsNodes.docker.textContent = docker.toString(); + this.statsNodes.lxc.textContent = lxc.toString(); + } + }, + + getFilteredApps: function() { + var q = this.searchQuery; + var runtimeFilter = this.runtimeFilter; + var state = this.stateFilter; + + return this.apps.filter(function(app) { + var runtime = (app.runtime || app.type || '').toLowerCase(); + var desc = ((app.description || '') + ' ' + (app.name || '') + ' ' + (app.id || '')).toLowerCase(); + var matchesRuntime = runtimeFilter === 'all' || runtime === runtimeFilter; + var matchesState = state === 'all' || + (state === 'installed' && app.state === 'installed') || + (state === 'available' && app.state !== 'installed'); + var matchesSearch = !q || desc.indexOf(q) !== -1; + return matchesRuntime && matchesState && matchesSearch; + }); + }, + + updateAppGrid: function() { + if (!this.appGrid) + return; + var apps = this.getFilteredApps(); + if (!apps.length) { + dom.content(this.appGrid, [ + E('div', { 'class': 'secubox-empty-state' }, [ + E('div', { 'class': 'secubox-empty-icon' }, '🕵️'), + E('div', { 'class': 'secubox-empty-title' }, _('No apps found')), + E('div', { 'class': 'secubox-empty-text' }, _('Adjust filters or add manifests under /usr/share/secubox/plugins/.')) + ]) + ]); + return; + } + dom.content(this.appGrid, apps.map(this.renderAppCard, this)); + }, + + renderAppCard: function(app) { + var runtime = (app.runtime || app.type || 'other').toLowerCase(); + var icon = RUNTIME_ICONS[runtime] || '🧩'; + var stateClass = app.state === 'installed' ? ' ok' : ''; + var badges = [ + E('span', { 'class': 'sb-app-tag' }, icon + ' ' + (app.runtime || app.type || _('Unknown'))) + ]; + if (app.category) + badges.push(E('span', { 'class': 'sb-app-tag' }, _('Category: %s').format(app.category))); + if (app.maturity) + badges.push(E('span', { 'class': 'sb-app-tag' }, _('Maturity: %s').format(app.maturity))); + if (app.version) + badges.push(E('span', { 'class': 'sb-app-tag sb-app-version' }, 'v' + app.version)); + + return E('div', { 'class': 'sb-app-card' }, [ + E('div', { 'class': 'sb-app-card-info' }, [ + E('div', { 'class': 'sb-app-name' }, [ + app.name || app.id, + E('span', { 'class': 'sb-app-state' + stateClass }, app.state || _('unknown')) + ]), + E('div', { 'class': 'sb-app-desc' }, app.description || _('No description provided')), + E('div', { 'class': 'sb-app-tags' }, badges) + ]), + E('div', { 'class': 'sb-app-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': this.showAppDetails.bind(this, app) + }, _('Details')), + (app.has_wizard ? E('button', { + 'class': 'cbi-button', + 'click': this.openAppWizard.bind(this, app) + }, _('Configure')) : null) + ]) + ]); + }, + + showAppDetails: function(app, ev) { + var self = this; + ui.showModal(_('Loading %s…').format(app.name || app.id), [E('div', { 'class': 'spinning' })]); + API.getAppManifest(app.id).then(function(manifest) { + ui.hideModal(); + manifest = manifest || {}; + var wizard = manifest.wizard || {}; + var packages = manifest.packages || []; + var ports = manifest.ports || []; + var volumes = manifest.volumes || []; + var requirements = manifest.requirements || {}; + var hardware = manifest.hardware || {}; + var network = manifest.network || {}; + var privileges = manifest.privileges || {}; + var profiles = (manifest.profiles && manifest.profiles.recommended) || manifest.profiles || []; + if (!Array.isArray(profiles)) + profiles = []; + + var makeRow = function(label, value) { + return E('div', { 'class': 'sb-app-detail-row' }, [ + E('strong', {}, label), + E('span', {}, value) + ]); + }; + + var detailRows = [ + makeRow(_('Runtime:'), manifest.runtime || app.runtime || manifest.type || app.type || _('Unknown')), + makeRow(_('Category:'), manifest.category || _('Unknown')), + makeRow(_('Maturity:'), manifest.maturity || _('Unspecified')), + makeRow(_('Version:'), manifest.version || app.version || '—'), + makeRow(_('State:'), app.state || _('unknown')) + ]; + + var requirementRows = []; + if (requirements.arch && requirements.arch.length) + requirementRows.push(makeRow(_('Architectures:'), requirements.arch.join(', '))); + if (requirements.min_ram_mb) + requirementRows.push(makeRow(_('Min RAM:'), _('%s MB').format(requirements.min_ram_mb))); + if (requirements.min_storage_mb) + requirementRows.push(makeRow(_('Min storage:'), _('%s MB').format(requirements.min_storage_mb))); + + var hardwareRows = []; + if (typeof hardware.usb === 'boolean') + hardwareRows.push(makeRow(_('USB access:'), hardware.usb ? _('Required') : _('Not needed'))); + if (typeof hardware.serial === 'boolean') + hardwareRows.push(makeRow(_('Serial access:'), hardware.serial ? _('Required') : _('Not needed'))); + + var privilegeRows = []; + if (typeof privileges.needs_usb === 'boolean') + privilegeRows.push(makeRow(_('USB privileges:'), privileges.needs_usb ? _('Required') : _('Not needed'))); + if (typeof privileges.needs_serial === 'boolean') + privilegeRows.push(makeRow(_('Serial privileges:'), privileges.needs_serial ? _('Required') : _('Not needed'))); + if (typeof privileges.needs_net_admin === 'boolean') + privilegeRows.push(makeRow(_('Net admin:'), privileges.needs_net_admin ? _('Required') : _('Not needed'))); + + var networkRows = []; + if ((network.inbound_ports || []).length) + networkRows.push(makeRow(_('Inbound ports:'), network.inbound_ports.join(', '))); + if ((network.protocols || []).length) + networkRows.push(makeRow(_('Protocols:'), network.protocols.join(', '))); + if (typeof network.outbound_only === 'boolean') + networkRows.push(makeRow(_('Network mode:'), network.outbound_only ? _('Outbound only') : _('Inbound/Outbound'))); + + var cliCommands = E('pre', { 'class': 'sb-app-cli' }, [ + 'secubox-app install ' + app.id + '\n', + (wizard.fields && wizard.fields.length ? 'secubox-app wizard ' + app.id + '\n' : ''), + 'secubox-app status ' + app.id + '\n', + 'secubox-app remove ' + app.id + ]); + + var sections = [ + E('p', { 'class': 'sb-app-desc' }, manifest.description || app.description || ''), + E('div', { 'class': 'sb-app-detail-grid' }, detailRows), + requirementRows.length ? E('div', { 'class': 'sb-app-detail-list' }, [ + E('strong', {}, _('Requirements')), + E('div', { 'class': 'sb-app-detail-grid' }, requirementRows) + ]) : '', + hardwareRows.length ? E('div', { 'class': 'sb-app-detail-list' }, [ + E('strong', {}, _('Hardware')), + E('div', { 'class': 'sb-app-detail-grid' }, hardwareRows) + ]) : '', + privilegeRows.length ? E('div', { 'class': 'sb-app-detail-list' }, [ + E('strong', {}, _('Privileges')), + E('div', { 'class': 'sb-app-detail-grid' }, privilegeRows) + ]) : '', + networkRows.length ? E('div', { 'class': 'sb-app-detail-list' }, [ + E('strong', {}, _('Network')), + E('div', { 'class': 'sb-app-detail-grid' }, networkRows) + ]) : '', + packages.length ? E('div', { 'class': 'sb-app-detail-list' }, [ + E('strong', {}, _('Packages')), + E('ul', {}, packages.map(function(pkg) { return E('li', {}, pkg); })) + ]) : '', + ports.length ? E('div', { 'class': 'sb-app-detail-list' }, [ + E('strong', {}, _('Ports')), + E('ul', {}, ports.map(function(port) { + var label = [port.name || 'port', port.protocol || '', port.port || ''].filter(Boolean).join(' · '); + return E('li', {}, label); + })) + ]) : '', + volumes.length ? E('div', { 'class': 'sb-app-detail-list' }, [ + E('strong', {}, _('Volumes')), + E('ul', {}, volumes.map(function(volume) { return E('li', {}, volume); })) + ]) : '', + profiles.length ? E('div', { 'class': 'sb-app-detail-list' }, [ + E('strong', {}, _('Profiles')), + E('ul', {}, profiles.map(function(profile) { return E('li', {}, profile); })) + ]) : '', + E('div', { 'class': 'sb-app-detail-list' }, [ + E('strong', {}, _('CLI commands')), + cliCommands + ]) + ]; + + var actions = [ + E('button', { + 'class': 'cbi-button cbi-button-cancel', + 'click': ui.hideModal + }, _('Close')), + (app.has_wizard ? E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': function() { + ui.hideModal(); + self.openAppWizard(app); + } + }, _('Launch wizard')) : null) + ].filter(Boolean); + + ui.showModal(app.name || app.id, [ + E('div', { 'class': 'sb-app-detail-body' }, sections), + E('div', { 'class': 'right', 'style': 'margin-top:16px;' }, actions) + ]); + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, err && err.message ? err.message : _('Unable to load manifest')), 'error'); + }); + }, + + openAppWizard: function(app) { + var self = this; + ui.showModal(_('Loading %s wizard…').format(app.name || app.id), [E('div', { 'class': 'spinning' })]); + API.getAppManifest(app.id).then(function(manifest) { + ui.hideModal(); + manifest = manifest || {}; + var wizard = manifest.wizard || {}; + var fields = wizard.fields || []; + if (!fields.length) { + ui.addNotification(null, E('p', {}, _('No wizard metadata for this app.')), 'warn'); + return; + } + var form = E('div', { 'class': 'sb-app-wizard-form' }, fields.map(function(field) { + return E('div', { 'class': 'sb-form-group' }, [ + E('label', {}, field.label || field.id), + E('input', { + 'class': 'sb-wizard-input', + 'name': field.id, + 'type': field.type || 'text', + 'placeholder': field.placeholder || '' + }) + ]); + })); + ui.showModal(_('Configure %s').format(app.name || app.id), [ + form, + E('div', { 'class': 'right', 'style': 'margin-top:16px;' }, [ + E('button', { + 'class': 'cbi-button cbi-button-cancel', + 'click': ui.hideModal + }, _('Cancel')), + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': function() { + self.submitAppWizard(app.id, form, fields); + } + }, _('Apply')) + ]) + ]); + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, err && err.message ? err.message : _('Failed to load wizard')), 'error'); + }); + }, + + submitAppWizard: function(appId, form, fields) { + var values = {}; + fields.forEach(function(field) { + var input = form.querySelector('[name="' + field.id + '"]'); + if (input && input.value !== '') + values[field.id] = input.value; + }); + ui.showModal(_('Saving…'), [E('div', { 'class': 'spinning' })]); + API.applyAppWizard(appId, values).then(function(result) { + ui.hideModal(); + if (result && result.success) { + ui.addNotification(null, E('p', {}, _('Wizard applied.')), 'info'); + } else { + ui.addNotification(null, E('p', {}, _('Failed to apply wizard.')), 'error'); + } + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, err && err.message ? err.message : _('Failed to apply wizard.')), 'error'); + }); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-secubox/root/usr/libexec/rpcd/luci.secubox b/luci-app-secubox/root/usr/libexec/rpcd/luci.secubox index 8cf6769..a718aa4 100755 --- a/luci-app-secubox/root/usr/libexec/rpcd/luci.secubox +++ b/luci-app-secubox/root/usr/libexec/rpcd/luci.secubox @@ -61,6 +61,46 @@ DEFAULT_STORAGE_PATH="/srv/secubox" SECOBOX_APP="/usr/sbin/secubox-app" OPKG_UPDATED=0 +manifest_files() { + if [ -d "$PLUGIN_DIR/catalog" ]; then + for file in "$PLUGIN_DIR"/catalog/*.json; do + [ -f "$file" ] || continue + echo "$file" + done + fi + for file in "$PLUGIN_DIR"/*/manifest.json; do + [ -f "$file" ] || continue + echo "$file" + done +} + +manifest_file_for_id() { + local id="$1" + local catalog="$PLUGIN_DIR/catalog/$id.json" + local legacy="$PLUGIN_DIR/$id/manifest.json" + if [ -f "$catalog" ]; then + printf '%s' "$catalog" + return 0 + fi + if [ -f "$legacy" ]; then + printf '%s' "$legacy" + return 0 + fi + return 1 +} + +validate_manifest_json() { + local manifest="$1" + local id name runtime packages category + id=$(jsonfilter -s "$manifest" -e '@.id' 2>/dev/null) + name=$(jsonfilter -s "$manifest" -e '@.name' 2>/dev/null) + category=$(jsonfilter -s "$manifest" -e '@.category' 2>/dev/null) + runtime=$(jsonfilter -s "$manifest" -e '@.runtime' 2>/dev/null) + [ -n "$runtime" ] || runtime=$(jsonfilter -s "$manifest" -e '@.type' 2>/dev/null) + packages=$(jsonfilter -s "$manifest" -e '@.packages[*]' 2>/dev/null) + [ -n "$id" ] && [ -n "$name" ] && [ -n "$runtime" ] && [ -n "$category" ] && [ -n "$packages" ] +} + # Module registry - auto-detected from /usr/libexec/rpcd/ detect_modules() { local modules="" @@ -1259,30 +1299,44 @@ list_apps() { ensure_directory "$PLUGIN_DIR" json_init json_add_array "apps" - local manifest_file - for manifest_file in "$PLUGIN_DIR"/*/manifest.json; do + local manifest_file manifest id name runtime type version description state wizard_field category maturity seen_ids="" + while IFS= read -r manifest_file; do [ -f "$manifest_file" ] || continue - local manifest manifest=$(cat "$manifest_file") - local id name type version description state wizard_field id=$(jsonfilter -s "$manifest" -e '@.id' 2>/dev/null) + [ -n "$id" ] || continue + case " $seen_ids " in + *" $id "*) continue ;; + esac + if ! validate_manifest_json "$manifest"; then + continue + fi + seen_ids="$seen_ids $id" name=$(jsonfilter -s "$manifest" -e '@.name' 2>/dev/null) type=$(jsonfilter -s "$manifest" -e '@.type' 2>/dev/null) + runtime=$(jsonfilter -s "$manifest" -e '@.runtime' 2>/dev/null) + [ -n "$type" ] || type="$runtime" version=$(jsonfilter -s "$manifest" -e '@.version' 2>/dev/null) description=$(jsonfilter -s "$manifest" -e '@.description' 2>/dev/null) - [ -n "$id" ] || continue + category=$(jsonfilter -s "$manifest" -e '@.category' 2>/dev/null) + maturity=$(jsonfilter -s "$manifest" -e '@.maturity' 2>/dev/null) state=$(packages_state "$manifest") wizard_field=$(jsonfilter -s "$manifest" -e '@.wizard.fields[0].id' 2>/dev/null) json_add_object json_add_string "id" "$id" json_add_string "name" "$name" + json_add_string "runtime" "$runtime" json_add_string "type" "$type" json_add_string "version" "$version" json_add_string "description" "$description" + json_add_string "category" "$category" + json_add_string "maturity" "$maturity" json_add_string "state" "$state" json_add_boolean "has_wizard" "$([ -n "$wizard_field" ] && echo 1 || echo 0)" json_close_object - done + done < Remove packages listed in manifest status Show install state and run status action if defined update Run plugin update action or opkg upgrade + validate Verify manifest schema/metadata Environment: SECUBOX_PLUGINS_DIR Override manifest directory (default: /usr/share/secubox/plugins) @@ -110,11 +111,33 @@ pkg_upgrade() { esac } +manifest_files() { + if [ -d "$PLUGINS_DIR/catalog" ]; then + for file in "$PLUGINS_DIR"/catalog/*.json; do + [ -f "$file" ] || continue + echo "$file" + done + fi + for file in "$PLUGINS_DIR"/*/manifest.json; do + [ -f "$file" ] || continue + echo "$file" + done +} + manifest_path() { local id="$1" - local file="$PLUGINS_DIR/$id/manifest.json" - [ -f "$file" ] || { err "Manifest not found for '$id' ($file)"; exit 1; } - printf '%s' "$file" + local catalog="$PLUGINS_DIR/catalog/$id.json" + local legacy="$PLUGINS_DIR/$id/manifest.json" + if [ -f "$catalog" ]; then + printf '%s' "$catalog" + return 0 + fi + if [ -f "$legacy" ]; then + printf '%s' "$legacy" + return 0 + fi + err "Manifest not found for '$id'" + exit 1 } manifest_field() { @@ -132,6 +155,38 @@ manifest_action() { jsonfilter -f "$file" -e "@.actions.$1" 2>/dev/null || true } +manifest_runtime() { + local file="$1" + local value + value=$(manifest_field "$file" '@.type') + [ -n "$value" ] || value=$(manifest_field "$file" '@.runtime') + printf '%s' "$value" +} + +validate_manifest_file() { + require_jsonfilter + local file="$1" + local id name runtime packages category + id=$(manifest_field "$file" '@.id') + name=$(manifest_field "$file" '@.name') + category=$(manifest_field "$file" '@.category') + runtime=$(manifest_runtime "$file") + packages=$(manifest_packages "$file") + if [ -z "$id" ] || [ -z "$name" ] || [ -z "$runtime" ]; then + warn "Skipping manifest $file (missing id/name/runtime)" + return 1 + fi + if [ -z "$packages" ]; then + warn "Manifest $id has no packages defined" + return 1 + fi + if [ -z "$category" ]; then + warn "Manifest $id missing category" + return 1 + fi + return 0 +} + plugin_state() { local file="$1" local pkgs pkg missing=0 installed=0 total=0 @@ -158,16 +213,28 @@ plugin_state() { list_plugins() { require_jsonfilter ensure_pkg_mgr - printf '%-16s %-22s %-10s %-10s\n' "ID" "Name" "Type" "State" - printf '%-16s %-22s %-10s %-10s\n' "--" "----" "----" "-----" - find "$PLUGINS_DIR" -mindepth 2 -maxdepth 2 -name manifest.json | sort | while read -r file; do - local id name type state + local seen_ids="" + printf '%-18s %-24s %-10s %-10s %-10s\n' "ID" "Name" "Runtime" "Category" "State" + printf '%-18s %-24s %-10s %-10s %-10s\n' "--" "----" "-------" "--------" "-----" + while IFS= read -r file; do + [ -f "$file" ] || continue + if ! validate_manifest_file "$file"; then + continue + fi + local id name runtime state category id=$(manifest_field "$file" '@.id') name=$(manifest_field "$file" '@.name') - type=$(manifest_field "$file" '@.type') + runtime=$(manifest_runtime "$file") + category=$(manifest_field "$file" '@.category') + case " $seen_ids " in + *" $id "*) continue ;; + esac + seen_ids="$seen_ids $id" state=$(plugin_state "$file") - printf '%-16s %-22s %-10s %-10s\n' "$id" "${name:-Unknown}" "${type:-?}" "$state" - done + printf '%-18s %-24s %-10s %-10s %-10s\n' "$id" "${name:-Unknown}" "${runtime:-unknown}" "${category:-?}" "$state" + done < Dict[str, int]: + runtime = (manifest.get("runtime") or manifest.get("type") or "native").lower() + specs = dict(RUNTIME_DEFAULTS.get(runtime, RUNTIME_DEFAULTS["native"])) + category = (manifest.get("category") or "").lower() + if category in CATEGORY_DEFAULTS: + for key, value in CATEGORY_DEFAULTS[category].items(): + specs[key] = max(specs.get(key, 0), value) + if manifest_id in SPEC_OVERRIDES: + specs.update(SPEC_OVERRIDES[manifest_id]) + return specs + + +def compute_arch(manifest_id: str, runtime: str) -> List[str]: + if manifest_id in ARCH_OVERRIDES: + return ARCH_OVERRIDES[manifest_id] + return ARCH_DEFAULTS.get(runtime, ARCH_DEFAULTS["native"]) + + +def apply_updates(path: Path) -> bool: + data = json.loads(path.read_text()) + manifest_id = data.get("id") or path.stem + runtime = (data.get("runtime") or data.get("type") or "native").lower() + specs = compute_specs(manifest_id, data) + requirements = data.setdefault("requirements", {}) + changed = False + + for key, value in specs.items(): + if requirements.get(key) != value: + requirements[key] = value + changed = True + + arch = compute_arch(manifest_id, runtime) + if requirements.get("arch") != arch: + requirements["arch"] = arch + changed = True + + if changed: + path.write_text(json.dumps(data, indent=2) + "\n") + return changed + + +def main() -> None: + parser = argparse.ArgumentParser(description="Refresh manifest requirement metadata.") + parser.add_argument("--check", action="store_true", help="Only report drifts.") + args = parser.parse_args() + + if not MANIFEST_DIR.is_dir(): + raise SystemExit(f"Manifest directory not found: {MANIFEST_DIR}") + + dirty = [] + for manifest_path in sorted(MANIFEST_DIR.glob("*.json")): + if args.check: + original = manifest_path.read_text() + changed = apply_updates(manifest_path) + if changed: + dirty.append(manifest_path) + manifest_path.write_text(original) + else: + if apply_updates(manifest_path): + print(f"[updated] {manifest_path.relative_to(REPO_ROOT)}") + + if args.check: + if dirty: + for path in dirty: + print(f"[drift] {path.relative_to(REPO_ROOT)}") + raise SystemExit(1) + print("All manifest specs look good.") + + +if __name__ == "__main__": + main()