diff --git a/containers/libreportal/frontend/components/apps/core/css/apps.css b/containers/libreportal/frontend/components/apps/core/css/apps.css
index 108ac7f..53b7504 100644
--- a/containers/libreportal/frontend/components/apps/core/css/apps.css
+++ b/containers/libreportal/frontend/components/apps/core/css/apps.css
@@ -217,6 +217,60 @@
line-height: 1;
}
+/* Registry catalog ("marketplace") cards — apps published in the signed index
+ but not on this box yet. Indigo family: distinct from green (installed),
+ gray (not installed) and amber (improvements). */
+.app-card.registry {
+ border-color: rgba(99, 102, 241, 0.35);
+}
+
+.app-tag.available-tag {
+ background: rgba(99, 102, 241, 0.30);
+ color: #a5b4fc;
+ border-color: rgba(99, 102, 241, 0.65);
+ transform: translateY(-2px);
+}
+
+.app-tag.available-tag::before {
+ content: '↓';
+ margin-right: 5px;
+ font-weight: 700;
+ line-height: 1;
+}
+
+/* Official trust badge — teal check: this entry is published and signed by
+ the LibrePortal team key (the box verified the signature). */
+.app-tag.trust-badge {
+ background: rgba(34, 211, 238, 0.22);
+ color: #67e8f9;
+ border-color: rgba(34, 211, 238, 0.55);
+ transform: translateY(-2px);
+}
+
+.app-tag.trust-badge::before {
+ content: '✓';
+ margin-right: 5px;
+ font-weight: 700;
+ line-height: 1;
+}
+
+/* The Add button — indigo to match the Available pill (install stays green). */
+.app-card-actions button[class*="add-btn"] {
+ background: #6366f1 !important;
+ color: #ffffff !important;
+ border: 1px solid #6366f1 !important;
+}
+
+.app-card-actions button[class*="add-btn"]:hover {
+ background: #4f46e5 !important;
+ border-color: #4f46e5 !important;
+}
+
+.app-card-actions button[class*="add-btn"]:disabled {
+ opacity: 0.65;
+ cursor: default;
+}
+
/* Clickable tags (category / installed-status) — jump to that filter view */
.app-tag.clickable {
cursor: pointer;
diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js
index 3d9bc54..3976b83 100644
--- a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js
+++ b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js
@@ -153,6 +153,11 @@ Object.assign(AppsManager.prototype, {
const card = document.createElement('div');
card.className = 'app-card';
if (app.installed) card.classList.add('installed');
+ // Registry catalog card ("Available — Add"): no local definition exists
+ // yet, so there is no detail page to open — the card is the whole surface
+ // until Add lands the definition and it becomes a normal Install card.
+ const isRegistry = !!app.registry;
+ if (isRegistry) card.classList.add('registry');
// Searchable text for the sidebar search box. Combined name +
// description + long description + category, lowercased once here
@@ -227,30 +232,40 @@ Object.assign(AppsManager.prototype, {
}
}
+ // Registry cards: an "Available" pill + official trust badge instead of
+ // the install-state pill; the top area is not clickable (no detail page).
+ const statusTag = isRegistry
+ ? `Available${app.trust === 'official' ? `Official` : ''}`
+ : `${status}`;
+ const topAttrs = isRegistry ? '' : `style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')"`;
+ const actionButton = isRegistry
+ ? ``
+ : ``;
+
card.innerHTML = `
-
+
-
${app.name.split(' - ')[0].trim()}
+
${app.name.split(' - ')[0].trim()}
${descriptionTag}
${categoryTag}
${instanceCountChip}
- ${status}
+ ${statusTag}
${formattedLongDescription ? `
${formattedLongDescription}
` : ''}
-
+ ${actionButton}
${serviceTrigger}
`;
-
+
return card;
},
// Populate inline service trigger popups for installed apps
diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js
index 32e4a62..58c2251 100755
--- a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js
+++ b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js
@@ -98,6 +98,20 @@ class AppsManager {
return;
}
+ // Registry Add — the definition landed (or the add failed); either way the
+ // card's state now comes from regenerated data, so reload + repaint the
+ // grid: on success the Available card becomes a normal Install card, on
+ // failure it re-renders with its Add button re-enabled.
+ if (action === 'app_add') {
+ this.clearCache();
+ await this.reloadAppsData();
+ const p = window.location.pathname;
+ if (p === '/apps' || p.startsWith('/apps/')) {
+ this.renderApps(window.appsCategory || 'all');
+ }
+ return;
+ }
+
// First-install welcome modal — only on the very first successful install per app per browser.
if (action === 'install' && status === 'completed' && appName) {
const key = `libreportal.welcomeShown.${String(appName).toLowerCase()}`;
@@ -257,6 +271,37 @@ class AppsManager {
// Filter apps by category
let filteredApps = appsData.apps || [];
+ // Registry catalog (marketplace): apps published in the signed index but
+ // not defined on this box render as "Available — Add" cards. The catalog
+ // is optional data — any failure means "no registry cards", never a
+ // broken grid. Local definitions always win on a slug collision.
+ try {
+ const regRes = await fetch('/data/apps/generated/registry_catalog.json', { cache: 'no-store' });
+ if (regRes.ok) {
+ const reg = await regRes.json();
+ const localSlugs = new Set(filteredApps.map(a => (a.command || '').split(' ').pop()));
+ const registryApps = (reg.apps || [])
+ .filter(e => e && e.app && !e.defined && !e.installed && !localSlugs.has(e.app))
+ .map(e => ({
+ name: `${e.title || e.app} - ${e.description || e.why || ''}`,
+ description: e.description || e.why || '',
+ longDescription: e.long_description || '',
+ category: String(e.category || '').toLowerCase(),
+ categories: e.category ? [String(e.category).toLowerCase()] : [],
+ installed: false,
+ registry: true,
+ artifactId: e.id,
+ slug: e.app,
+ trust: e.trust || 'official',
+ publisher: e.publisher || '',
+ icon: e.icon || '/core/icons/apps/default.svg',
+ command: `libreportal app add ${e.app}`,
+ config: {}
+ }));
+ filteredApps = filteredApps.concat(registryApps);
+ }
+ } catch (_) { /* catalog unavailable — grid renders local apps only */ }
+
// Instances are managed inside their type's detail page (the family
// switcher), never as standalone grid cards — hide any app that declares
// INSTANCE_OF so the grid shows one card per app type.
@@ -277,12 +322,9 @@ class AppsManager {
});
}
- // Sort installed apps first
- filteredApps.sort((a, b) => {
- if (a.installed && !b.installed) return -1;
- if (!a.installed && b.installed) return 1;
- return 0;
- });
+ // Sort: installed first, then local not-installed, registry cards last.
+ const rank = (a) => a.installed ? 0 : (a.registry ? 2 : 1);
+ filteredApps.sort((a, b) => rank(a) - rank(b));
// Cache the result
this.cache.set(category, filteredApps);
@@ -1146,6 +1188,18 @@ class AppsManager {
+ // Registry catalog "Add" — dispatch the app_add task that verifies the
+ // signed bundle and drops the definition in (mutations via tasks, as ever).
+ addRegistryApp(slug, btn = null) {
+ if (!/^[a-z0-9][a-z0-9_]{0,31}$/.test(String(slug || ''))) return;
+ if (!window.tasksManager?.router) {
+ window.notifications?.error?.('Task system not ready — try again in a moment.');
+ return;
+ }
+ if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; }
+ window.tasksManager.router.routeAction('app_add', { slug });
+ }
+
async installApp(appName) {
const installedApp = (window.apps || []).find(a =>
(a.command || '').endsWith(` ${appName}`) || a.name === appName
diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-format.js b/containers/libreportal/frontend/components/tasks/js/tasks-format.js
index 06e9d58..1fdb727 100644
--- a/containers/libreportal/frontend/components/tasks/js/tasks-format.js
+++ b/containers/libreportal/frontend/components/tasks/js/tasks-format.js
@@ -141,6 +141,7 @@ Object.assign(TasksManager.prototype, {
'updater_check': 'Check for Updates', 'updater_apply': 'Update',
'updater_apply_all': 'Update All', 'updater_rollback': 'Roll Back',
'artifact_apply': 'Apply Hotfix', 'artifact_revert': 'Revert Hotfix',
+ 'app_add': 'Add App',
'setup-config': 'Apply Configuration',
'setup-finalize': 'Finalize Setup'
};
diff --git a/containers/libreportal/frontend/core/tasks/js/task-actions.js b/containers/libreportal/frontend/core/tasks/js/task-actions.js
index 8ef2053..8864be0 100755
--- a/containers/libreportal/frontend/core/tasks/js/task-actions.js
+++ b/containers/libreportal/frontend/core/tasks/js/task-actions.js
@@ -358,6 +358,16 @@ async configUpdate(changes) {
} catch (error) { throw new Error(`Failed to revert hotfix ${id}: ${error.message}`); }
}
+ // Registry catalog (marketplace) — add an app definition from the signed
+ // index via `libreportal app add` as a task. Slug charset-guarded before it
+ // enters the command string (defense in depth; the CLI re-guards).
+ async appAdd(slug) {
+ if (!slug || !/^[a-z0-9][a-z0-9_]{0,31}$/.test(slug)) throw new Error('Invalid app slug');
+ try {
+ return await this.executeTask('app_add', slug, `libreportal app add ${slug}`, `Add ${slug}`);
+ } catch (error) { throw new Error(`Failed to add ${slug}: ${error.message}`); }
+ }
+
/**
* Create a task object
*/
diff --git a/containers/libreportal/frontend/core/tasks/js/task-router.js b/containers/libreportal/frontend/core/tasks/js/task-router.js
index c94ac2c..a2e44f1 100755
--- a/containers/libreportal/frontend/core/tasks/js/task-router.js
+++ b/containers/libreportal/frontend/core/tasks/js/task-router.js
@@ -95,6 +95,11 @@ class TaskRouter {
case 'artifact_revert':
return await this.actions.artifactRevert(params.id);
+ // Registry catalog (marketplace) — add an app definition from the
+ // signed index; the App Center's "Available — Add" cards route here.
+ case 'app_add':
+ return await this.actions.appAdd(params.slug);
+
default:
throw new Error(`Unknown action: ${action}`);
}