Show the apps built into LibrePortal on the release catalog page

The catalog page at the release host only listed add-ons from the signed
index, and every current app ships inside LibrePortal itself, so a first
release would have shown an empty catalog. make_release.sh now also writes
the release's app list (apps.json, plus icons) next to latest.json, chosen
the same way as the website's app grid. The page shows those apps as
"Built in" with a `libreportal app install` command, and add-ons from the
index as before. A built-in app hides an add-on with the same name, as it
does on a box.

The list is display data only: boxes never read it. The Submissions badge
no longer shows as an empty dot when there are no submissions.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
LibrePortal
2026-09-17 01:55:05 +01:00
parent ed18bf62f6
commit 670f519c93
3 changed files with 131 additions and 36 deletions
+2 -1
View File
@@ -17,7 +17,8 @@ server {
default_type application/json;
add_header Cache-Control "no-cache, must-revalidate";
}
location ~ /latest\.json$ {
# latest.json and the release's built-in app list (apps.json) too.
location ~ /(latest|apps)\.json$ {
default_type application/json;
add_header Cache-Control "no-cache, must-revalidate";
}
@@ -144,6 +144,7 @@
.app-tag.available-tag::before { content: '↓'; margin-right: 5px; font-weight: 700; line-height: 1; }
.app-tag.installed-tag { background: rgba(var(--status-success-rgb),0.35); color: #86efac; border-color: rgba(var(--status-success-rgb),0.70); }
.app-tag.installed-tag::before { content: '✓'; margin-right: 5px; font-weight: 700; line-height: 1; }
.app-tag.builtin-tag { background: rgba(var(--accent-rgb),0.16); color: #7dd3fc; border-color: rgba(var(--accent-rgb),0.45); }
.app-tag.trust-badge { background: rgba(34,211,238,0.22); color: #67e8f9; border-color: rgba(34,211,238,0.55); }
.app-tag.trust-badge::before { content: '✓'; margin-right: 5px; font-weight: 700; line-height: 1; }
.app-tag.community-badge { background: rgba(var(--status-warning-rgb),0.2); color: #fcd34d; border-color: rgba(var(--status-warning-rgb),0.5); }
@@ -190,6 +191,7 @@
background: rgba(var(--status-warning-rgb),0.9); color: #1a1206; font-size: 0.72rem; font-weight: 700;
display: inline-flex; align-items: center; justify-content: center;
}
.side-pin-badge[hidden] { display: none; } /* same as .focusbar: keep the hidden attr working */
/* Info + submissions views (rendered into #main). */
.doc { max-width: 820px; margin: 22px; }
@@ -290,7 +292,7 @@
}
var _ph = parseHash();
var state = { view: _ph.view, apps: [], cat: 'all', q: '', focus: _ph.focus,
idx: null, channel: 'stable', signed: false, submissions: null };
idx: null, release: null, channel: 'stable', signed: false, submissions: null };
var main = document.getElementById('main');
var cats = document.getElementById('cats');
var topchip = document.getElementById('topchip');
@@ -335,9 +337,11 @@
function card(a) {
var iconInner = a.icon ? '<img src="' + esc(a.icon) + '" alt="" onerror="this.style.display=\'none\'">' : '<img src="libreportal_catalog.svg" alt="">';
var trust = a.trust === 'official' ? '<span class="app-tag trust-badge">Official</span>' : '<span class="app-tag community-badge">' + esc(a.trust) + '</span>';
var stateTag = a.installed ? '<span class="app-tag installed-tag">Installed</span>' : '<span class="app-tag available-tag">Available</span>';
var stateTag = a.builtin ? '<span class="app-tag builtin-tag">Built in</span>'
: (a.installed ? '<span class="app-tag installed-tag">Installed</span>' : '<span class="app-tag available-tag">Available</span>');
var descTag = a.description ? '<span class="app-tag description-tag">' + esc(a.description) + '</span>' : '';
var btn = a.installed ? '<button class="installed-btn" disabled>Installed</button>' : '<button data-slug="' + esc(a.slug) + '">Add</button>';
var btn = a.installed ? '<button class="installed-btn" disabled>Installed</button>'
: '<button data-slug="' + esc(a.slug) + '" data-cmd="' + (a.builtin ? 'install' : 'add') + '">' + (a.builtin ? 'Install' : 'Add') + '</button>';
return '<div class="app-card"><div class="app-card-top"><div class="app-card-icon">' + iconInner + '</div>' +
'<div class="app-card-content"><div class="app-card-title">' + esc(a.title) + '</div>' +
'<div class="app-card-tags">' + descTag + '<span class="app-tag category-tag">' + esc(a.category || 'app') + '</span>' + stateTag + trust + '</div></div></div>' +
@@ -345,13 +349,18 @@
'<div class="app-card-actions">' + btn + '</div></div>';
}
function statusHtml() {
if (!state.idx) return '<span class="warn">No catalog published on this host yet.</span>';
var avail = state.apps.filter(function (a) { return !a.installed; }).length;
return (state.signed ? '<span class="ok">✓ signed</span>' : '<span class="warn">unsigned</span>') +
'<span>' + avail + ' available</span><span>' + state.apps.length + ' apps</span>' +
'<span>channel ' + esc(state.channel) + '</span>' +
'<span>serial ' + esc(String(state.idx.index_serial != null ? state.idx.index_serial : '?')) + '</span>' +
'<span>published ' + esc(state.idx.generated_at || '?') + '</span>';
if (!state.idx && !state.release) return '<span class="warn">No catalog published on this host yet.</span>';
var builtin = state.apps.filter(function (a) { return a.builtin; }).length;
var parts = [];
if (state.release) parts.push('<span>LibrePortal ' + esc(state.release.version || '?') + '</span>', '<span>' + builtin + ' built in</span>');
if (state.idx) {
parts.push(state.signed ? '<span class="ok">✓ add-ons signed</span>' : '<span class="warn">add-ons unsigned</span>',
'<span>' + (state.apps.length - builtin) + ' add-ons</span>',
'<span>serial ' + esc(String(state.idx.index_serial != null ? state.idx.index_serial : '?')) + '</span>',
'<span>published ' + esc(state.idx.generated_at || '?') + '</span>');
}
parts.push('<span>channel ' + esc(state.channel) + '</span>');
return parts.join('');
}
function renderBrowse() {
main.innerHTML = '<div class="status-strip" id="status"></div><div class="focusbar" id="focusbar" hidden></div>' +
@@ -380,7 +389,8 @@
// ---- how it works ----
function howtoHtml() {
return '<div class="doc"><h1>How it works</h1>' +
'<p class="lead">This is a signed catalog of apps you can add to your LibrePortal box. Browsing happens here; adding happens on your box.</p>' +
'<p class="lead">These are the apps you can run on a LibrePortal box. Browsing happens here; installing happens on your box.</p>' +
'<div class="doc-card"><h2>Built-in apps</h2><p>Apps marked <strong>Built in</strong> come with LibrePortal itself. Click <strong>Install</strong> to copy its <code>libreportal app install &lt;slug&gt;</code> command, or install it from your App Center.</p></div>' +
'<div class="doc-card"><h2>Add an app</h2><p>Click a cards <strong>Add</strong> to copy its <code>libreportal app add &lt;slug&gt;</code> command and run it on your box. Or, once your box has scanned this catalog, the app shows up in your App Center grid as an “Available” card you can add with one click.</p></div>' +
'<div class="doc-card"><h2>Point your box at this catalog</h2><p>Set <code>CFG_RELEASE_BASE_URL</code> to this sites address (Config → Developer Mode). Your box fetches and verifies this catalog on its next scan, and its App Center starts offering these apps.</p></div>' +
'<div class="doc-card"><h2>How trust works</h2><p>The catalog is signed with <a href="https://jedisct1.github.io/minisign/" target="_blank" rel="noopener noreferrer">minisign</a>. Your box verifies that signature against its own root-owned key <em>before</em> adding anything — a website, even this one, can never forge or tamper with an app. An unsigned catalog is browseable, but adds are refused.</p></div>' +
@@ -454,33 +464,62 @@
}
// ---- data ----
// A channel has two optional lists: apps.json (the apps built into its
// current release, written by make_release.sh) and index.json (signed
// add-ons, written by make_app.sh). The first channel with either is shown.
// A built-in app wins over an add-on of the same slug, as it does on a box.
function getJson(url) {
return fetch(url, { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.catch(function () { return null; });
}
function load(i) {
i = i || 0;
if (i >= CHANNELS.length) { state.idx = null; topchip.textContent = 'no catalog'; render(); return; }
if (i >= CHANNELS.length) { state.idx = null; state.release = null; topchip.textContent = 'no catalog'; render(); return; }
var ch = CHANNELS[i];
fetch(ch + '/index.json', { cache: 'no-store' })
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (idx) {
state.idx = idx; state.channel = ch;
state.apps = (idx.artifacts || []).filter(function (a) {
return a && a.type === 'app' && a.payload && a.payload.kind === 'bundle' && a.applies_when && a.applies_when.app;
}).map(function (a) {
var m = a.meta || {};
return {
slug: a.applies_when.app, title: a.title || a.applies_when.app,
description: m.description || a.why || '', long_description: m.long_description || '',
category: (m.category || '').toLowerCase(), trust: a.trust || 'official',
version: a.version || 1, installed: false, featured: !!m.featured,
icon: m.icon ? ch + '/payloads/icons/' + a.applies_when.app + '.' + m.icon.split('.').pop() : null
};
});
topchip.textContent = state.apps.length + ' apps · ' + ch;
render();
Promise.all([getJson(ch + '/index.json'), getJson(ch + '/apps.json')]).then(function (res) {
var idx = res[0], rel = res[1];
if (idx && !Array.isArray(idx.artifacts)) idx = null;
if (rel && !Array.isArray(rel.apps)) rel = null;
if (!idx && !rel) { load(i + 1); return; }
state.idx = idx; state.release = rel; state.channel = ch;
var builtin = (rel ? rel.apps : []).filter(function (a) {
return a && /^[a-z0-9][a-z0-9_]{0,31}$/.test(a.slug || '');
}).map(function (a) {
return {
slug: a.slug, title: a.title || a.slug,
description: a.description || '', long_description: a.long_description || '',
category: (a.category || '').toLowerCase(), trust: 'official',
version: rel.version, installed: false, featured: false, builtin: true,
icon: /^[a-z0-9_-]+\/icons\/[a-z0-9_]+\.(svg|png)$/.test(a.icon || '') ? a.icon : null
};
});
var have = {};
builtin.forEach(function (a) { have[a.slug] = true; });
var addons = (idx ? idx.artifacts : []).filter(function (a) {
return a && a.type === 'app' && a.payload && a.payload.kind === 'bundle' && a.applies_when && a.applies_when.app
&& !have[a.applies_when.app];
}).map(function (a) {
var m = a.meta || {};
return {
slug: a.applies_when.app, title: a.title || a.applies_when.app,
description: m.description || a.why || '', long_description: m.long_description || '',
category: (m.category || '').toLowerCase(), trust: a.trust || 'official',
version: a.version || 1, installed: false, featured: !!m.featured, builtin: false,
icon: m.icon ? ch + '/payloads/icons/' + a.applies_when.app + '.' + m.icon.split('.').pop() : null
};
});
state.apps = builtin.concat(addons).sort(function (a, b) {
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : 1;
});
topchip.textContent = state.apps.length + ' apps · ' + ch;
render();
if (idx) {
fetch(ch + '/index.json.minisig', { method: 'HEAD', cache: 'no-store' })
.then(function (r) { state.signed = r.ok; if (state.view === 'browse') render(); })
.catch(function () {});
})
.catch(function () { load(i + 1); });
}
});
}
// Submissions are operator-published data (like the catalog): a static
// submissions.json = { "submissions": [ {app,title,author,status,category,opened,url} ] }.
@@ -509,8 +548,10 @@
main.addEventListener('click', function (e) {
var add = e.target.closest('button[data-slug]');
if (add) {
var txt = 'libreportal app add ' + add.getAttribute('data-slug');
var done = function () { add.textContent = 'Copied ✓'; add.classList.add('copied'); setTimeout(function () { add.textContent = 'Add'; add.classList.remove('copied'); }, 1600); };
var verb = add.getAttribute('data-cmd') === 'install' ? 'install' : 'add';
var label = add.textContent;
var txt = 'libreportal app ' + verb + ' ' + add.getAttribute('data-slug');
var done = function () { add.textContent = 'Copied ✓'; add.classList.add('copied'); setTimeout(function () { add.textContent = label; add.classList.remove('copied'); }, 1600); };
if (navigator.clipboard) navigator.clipboard.writeText(txt).then(done, function () { add.textContent = txt; });
else done();
return;
+54 -1
View File
@@ -9,7 +9,10 @@
#
# Signing is required. Every install carries the real public key and refuses an
# unsigned release, so this fails closed unless LP_MINISIGN_SECKEY is set. The
# output lands in dist/<channel>/ laid out exactly like the host serves it.
# output lands in dist/<channel>/ laid out exactly like the host serves it:
# libreportal-<v>.tar.gz(.sha256,.minisig) the release
# latest.json the channel pointer boxes follow
# apps.json + icons/ the built-in apps, for the catalog page
#
# For a local test without the key, pass --unsigned. That build goes to
# dist-unsigned/<channel>/ (never dist/), and the install needs --no-verify-signature:
@@ -38,6 +41,7 @@ REF="${ARGS[1]:-HEAD}"
[[ "$CHANNEL" =~ ^[a-z0-9_-]+$ ]] || { echo "make_release: bad channel name '$CHANNEL'" >&2; exit 1; }
releaseRequireSigning make_release
command -v jq >/dev/null 2>&1 || { echo "make_release: jq is required" >&2; exit 1; }
VERSION="$(tr -d ' \t\n\r' < VERSION 2>/dev/null || true)"
[[ -n "$VERSION" ]] || { echo "make_release: VERSION file is empty or missing" >&2; exit 1; }
@@ -115,6 +119,51 @@ tar --sort=name --owner=0 --group=0 --numeric-owner -czf "$BUILD/$TARBALL" -C "$
( cd "$BUILD" && sha256sum "$TARBALL" > "$TARBALL.sha256" )
SHA="$(cut -d' ' -f1 < "$BUILD/$TARBALL.sha256")"
# apps.json + icons/: the apps BUILT INTO this release, for the catalog page at
# the release host (containers/libreportal_catalog), which otherwise only knows
# the add-ons in index.json. Display data only: boxes never read it, and the
# page trusts nothing it shows (the tarball's signature covers the real thing).
# Same selection as the website's app grid: a TITLE, not DEV_ONLY, not an
# instance, not the WebUI itself. Metadata is parsed line-wise, never sourced.
mkdir -p "$BUILD/icons"
APPS_JSON='[]'
for cfg in "$STAGE/$PREFIX"containers/*/*.config; do
slug="$(basename "$(dirname "$cfg")")"
[[ "$cfg" == */"$slug/$slug.config" && "$slug" != "libreportal" ]] || continue
title=""; category=""; description=""; long_description=""; skip=0
while IFS='=' read -r key val || [[ -n "$key" ]]; do
[[ "$key" == "CFG_${slug^^}_"* ]] || continue
val="${val%$'\r'}"
if [[ "$val" == \"* ]]; then val="${val#\"}"; val="${val%%\"*}"
else val="${val%%[[:space:]]#*}"; val="${val%"${val##*[![:space:]]}"}"; fi
case "${key#CFG_"${slug^^}"_}" in
TITLE) title="$val" ;;
CATEGORY) category="${val%%,*}" ;;
DESCRIPTION) description="$val" ;;
LONG_DESCRIPTION) long_description="$val" ;;
DEV_ONLY) [[ "${val,,}" == "true" ]] && skip=1 ;;
INSTANCE_OF) [[ -n "$val" ]] && skip=1 ;;
esac
done < "$cfg"
[[ -n "$title" && "$skip" == 0 ]] || continue
icon=""
for ext in svg png; do
if [[ -f "$(dirname "$cfg")/$slug.$ext" ]]; then
cp "$(dirname "$cfg")/$slug.$ext" "$BUILD/icons/$slug.$ext"
icon="$CHANNEL/icons/$slug.$ext"
break
fi
done
APPS_JSON="$(jq -c --arg slug "$slug" --arg title "$title" --arg category "${category,,}" \
--arg description "$description" --arg long_description "$long_description" --arg icon "$icon" \
'. + [{slug:$slug, title:$title, category:$category, description:$description,
long_description:$long_description} + (if $icon != "" then {icon:$icon} else {} end)]' <<<"$APPS_JSON")"
done
jq --arg version "$VERSION" --arg channel "$CHANNEL" \
'{schema:1, version:$version, channel:$channel, apps:(sort_by(.title | ascii_downcase))}' \
<<<"$APPS_JSON" > "$BUILD/apps.json"
APP_COUNT="$(jq '.apps | length' "$BUILD/apps.json")"
cat > "$BUILD/latest.json" <<EOF
{
"version": "$VERSION",
@@ -136,10 +185,14 @@ SIGNED=" tarball: $(releaseSignedNote)"
mv -f "$BUILD/$TARBALL" "$OUT/$TARBALL"
[[ -f "$BUILD/$TARBALL.minisig" ]] && mv -f "$BUILD/$TARBALL.minisig" "$OUT/$TARBALL.minisig"
mv -f "$BUILD/$TARBALL.sha256" "$OUT/$TARBALL.sha256"
mkdir -p "$OUT/icons"
cp -rf "$BUILD/icons/." "$OUT/icons/"
mv -f "$BUILD/apps.json" "$OUT/apps.json"
mv -f "$BUILD/latest.json" "$OUT/latest.json"
echo "$OUT/$TARBALL"
echo "$OUT/$TARBALL.sha256 ($SHA)"
echo "$OUT/apps.json ($APP_COUNT built-in apps, for the catalog page)"
echo "$OUT/latest.json"
echo "$SIGNED"
echo " ✓ SHA256SUMS ($MANIFEST_FILES files, inside tarball)"