openvibers/openvibe.shared Commits
Shared 4.1.0: every shell.page() site is an installable web app (manifest + app icons, served from the pinned copy) (#66)
Changes
@@@ -4,6 +4,23 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.1.0 — 2026-10-12++++- **Every `shell.page()` site is an installable web app, with no per-site code.** A new `icons/` directory ships++ four pre-rendered app icons (192 and 512, any and maskable) per app-icon tint variant — 8 sets, 32 palette PNGs,++ about 630 KB, since most sites share the network's tint — drawn by `scripts/build-icons.js` from++ `app-icon.svg({ site: variant, maskable })`; `icons/sites.json` gives each of the 35 services with a share card its++ name, tagline and variant (`social.iconSite`). The new `openvibe-shared/app-icons` module names them (`has`, `url`,++ `site`, `isIcon`, `path`), and `serve.js` answers them at `/shared/icons/<variant>[-maskable]-<size>.png` beside the++ browser files and share cards.++- `serve.js` now also answers `GET /shared/manifest/<service>.webmanifest` as `application/manifest+json`, built++ from the service's Contracts `site` block (name and tagline, committed in `icons/sites.json`) with the same++ hashed icon URLs, `start_url`/`scope` `/`, `display: standalone`, the app-icon background and the navbar's++ `#0d131d` theme colour. Unknown services 404.++- `shell.page()` adds `<link rel="manifest">` and `<link rel="apple-touch-icon">` for the page's service when it has++ icons, using the same service resolution as the share-card default (`service`, else `navbar.service`, else++ `footer.service`). `manifest: false` opts out, and a page whose `head` already carries a manifest link keeps it.++## 4.0.3 — 2026-10-11- Share cards: `run` (openvibe.run has a site block since openvibe-contracts 0.141.0) and a redrawn `host` (its tagline@@@ -20,9 +20,9 @@ were reconciled first.| Kind | Files ||---|---|| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `app-icons` (every site's installable app icon set: `has(service)`, `url(service, size, { maskable })`, `isIcon(name)`, `path(name)`; the pre-rendered PNGs are served at `/shared/icons/<service>[-maskable]-<size>.png` and named by the web app manifest `serve.js` serves at `/shared/manifest/<service>.webmanifest`), `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` || Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |--| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) |++| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js`, `scripts/build-icons.js`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) || Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR || Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once), `scripts/docs-status.js` (`node scripts/docs-status.js <dir> [--check]`: STATUS.json's contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the pinned releases; `--check` exits 1 on drift) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |@@@ -561,6 +561,16 @@ A server-rendered page puts these tags in its `<head>` itself, with the SEO headFrame around them: `openvibe-shared/shell` (`shell.page(o)`) composes exactly that — see "The pageshell" below.++### Installable sites++++Every page `shell.page()` renders is also an installable web app, with no per-site code. When the page's++service has a pre-rendered icon set (`openvibe-shared/app-icons`; `icons/<variant>[-maskable]-192|512.png`, one set per tint variant),++the shell adds `<link rel="manifest" href="/shared/manifest/<service>.webmanifest?v=<hash>">` and an++`<link rel="apple-touch-icon">` to its `<head>`. `serve.js` answers both from the site's pinned copy — the++manifest is built from the service's Contracts `site` block (name and tagline) with the hashed icon URLs —++so a home-screen install never depends on another host. `manifest: false` opts out for sites that serve++their own `/manifest.webmanifest`, and a page whose `head` already has a manifest link keeps it.++### The page shell (`shell.js`)```textnnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// openvibe-shared/app-icons — every OpenVibe site's installable app icon set.++//++// The icons are pre-rendered PNGs in icons/<variant>[-maskable]-<size>.png (192 and 512), one set per app-icon++// tint variant (most sites share the network's), drawn by scripts/build-icons.js from app-icon.svg({ site: variant,++// maskable }); icons/sites.json names each service's variant (social.iconSite). A site serves its++// pinned copy at /shared/icons/<name> through openvibe-shared/serve, and the web app manifest served at++// /shared/manifest/<service>.webmanifest points at the same hashed URLs. (The `icons` subpath is already the++// ring-icon module `ov-icons.js`, so the per-site icon set lives here.)++//++// appIcons.has(service) is there an icon set for this service id++// appIcons.url(service, size, { maskable }) '/shared/icons/<variant>[-maskable]-<size>.png?v=<hash>', or null++// appIcons.site(service) { name, description, icon } from icons/sites.json, or null++// appIcons.isIcon(name) 'icons/<variant>[-maskable]-<size>.png' names serve.js may answer++// appIcons.path(name) the file on disk (throws for anything that is not an icon)++//++// shell.page() uses appIcons.url(service, 192) for the apple-touch-icon when the page names its service.++// ═══════════════════════════════════════════════════════════════++const fs = require('fs');++const path = require('path');++const { hashAsset } = require('./assets');++++const DIR = path.join(__dirname, 'icons');++const NAME_RE = /^icons\/([a-z0-9][a-z0-9-]{0,40})-(maskable-)?(192|512)\.png$/;++const ID_RE = /^[a-z0-9][a-z0-9-]{0,40}$/;++const hashes = new Map();++let sites = null;++/** icons/sites.json: { <service>: { name, description, icon: <variant> } }, read once. */++function site(service) {++ if (!sites) { try { sites = JSON.parse(fs.readFileSync(path.join(DIR, 'sites.json'), 'utf8')); } catch { sites = {}; } }++ const id = String(service || '');++ return ID_RE.test(id) && Object.prototype.hasOwnProperty.call(sites, id) ? sites[id] : null;++}++++function isIcon(name) {++ const m = NAME_RE.exec(String(name || ''));++ return Boolean(m) && fs.existsSync(path.join(DIR, `${m[1]}-${m[2] || ''}${m[3]}.png`));++}++++function iconPath(name) {++ if (!isIcon(name)) throw new Error(`openvibe-shared/app-icons: ${name} is not an app icon`);++ return path.join(__dirname, name);++}++++const has = (service) => { const s = site(service); return Boolean(s && s.icon && isIcon(`icons/${s.icon}-192.png`)); };++++function url(service, size, { maskable = false } = {}) {++ const meta = site(service);++ if (!meta || !meta.icon) return null;++ const s = Number(size);++ if (s !== 192 && s !== 512) return null;++ const name = `icons/${meta.icon}-${maskable ? 'maskable-' : ''}${s}.png`;++ if (!isIcon(name)) return null;++ if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(iconPath(name))));++ return `/shared/${name}?v=${hashes.get(name)}`;++}++++module.exports = { has, url, site, isIcon, path: iconPath, DIR };nnew file mode 100644BBinary files /dev/null and b/icons/ai-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/ai-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/ai-maskable-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/ai-maskable-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/community-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/community-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/community-maskable-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/community-maskable-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/games-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/games-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/games-maskable-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/games-maskable-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/live-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/live-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/live-maskable-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/live-maskable-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/media-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/media-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/media-maskable-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/media-maskable-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/network-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/network-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/network-maskable-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/network-maskable-512.png differnnew file mode 100644@@@ -0,0 +1,177 @@++{++ "actor": {++ "name": "OpenVibe.Actor",++ "description": "Your agent for everything",++ "icon": "network"++ },++ "ai": {++ "name": "OpenVibe.AI",++ "description": "Many models, one API. Every answer explained.",++ "icon": "ai"++ },++ "blog": {++ "name": "OpenVibe.Blog",++ "description": "Long-form, yours to keep",++ "icon": "network"++ },++ "bot": {++ "name": "OpenVibe.Bot",++ "description": "An open control panel for robots",++ "icon": "network"++ },++ "chat": {++ "name": "OpenVibe.Chat",++ "description": "One chat for the network",++ "icon": "network"++ },++ "codes": {++ "name": "OpenVibe.Codes",++ "description": "Code with any agent",++ "icon": "network"++ },++ "community": {++ "name": "OpenVibe.Community",++ "description": "Pastes and posts",++ "icon": "community"++ },++ "coupons": {++ "name": "OpenVibe.Coupons",++ "description": "Codes that work",++ "icon": "network"++ },++ "deals": {++ "name": "OpenVibe.Deals",++ "description": "Deals worth sharing",++ "icon": "network"++ },++ "events": {++ "name": "OpenVibe.Events",++ "description": "Events your services can count on.",++ "icon": "network"++ },++ "food": {++ "name": "OpenVibe.Food",++ "description": "What should we eat?",++ "icon": "network"++ },++ "games": {++ "name": "OpenVibe.Games",++ "description": "Browser games",++ "icon": "games"++ },++ "help": {++ "name": "OpenVibe.Help",++ "description": "Answers for every OpenVibe site",++ "icon": "network"++ },++ "host": {++ "name": "OpenVibe.Host",++ "description": "Hosting, compared honestly",++ "icon": "network"++ },++ "inventory": {++ "name": "OpenVibe.Inventory",++ "description": "Your items, on every OpenVibe site.",++ "icon": "network"++ },++ "live": {++ "name": "OpenVibe.Live",++ "description": "Streams, clips and chat",++ "icon": "live"++ },++ "media": {++ "name": "OpenVibe.Media",++ "description": "VODs, clips, files",++ "icon": "media"++ },++ "media-hub": {++ "name": "OpenVibe.Video",++ "description": "Past streams, clips and your drive",++ "icon": "media"++ },++ "network": {++ "name": "OpenVibe.Network",++ "description": "Account and themes",++ "icon": "network"++ },++ "news": {++ "name": "OpenVibe.News",++ "description": "Sourced, not spun",++ "icon": "network"++ },++ "openre": {++ "name": "OpenRestream",++ "description": "Go live once, stream everywhere",++ "icon": "network"++ },++ "quest": {++ "name": "OpenVibe.Quest",++ "description": "Badges for what you really do",++ "icon": "network"++ },++ "rent": {++ "name": "OpenVibe.Rent",++ "description": "Find a place, or rent yours out",++ "icon": "network"++ },++ "reviews": {++ "name": "OpenVibe.Reviews",++ "description": "Reviews with receipts",++ "icon": "network"++ },++ "run": {++ "name": "OpenVibe.Run",++ "description": "Run a job on a node that can",++ "icon": "network"++ },++ "search": {++ "name": "OpenVibe.Search",++ "description": "Search the OpenVibe network.",++ "icon": "network"++ },++ "services": {++ "name": "OpenVibe.Services",++ "description": "Build on OpenVibe",++ "icon": "network"++ },++ "space": {++ "name": "OpenVibe.Space",++ "description": "Static sites, hosted by OpenVibe",++ "icon": "space"++ },++ "tips": {++ "name": "OpenVibe.Tips",++ "description": "Support creators",++ "icon": "network"++ },++ "tools": {++ "name": "OpenVibe.Tools",++ "description": "Every online tool",++ "icon": "tools"++ },++ "trade": {++ "name": "OpenVibe.Trade",++ "description": "Watchlists and alerts",++ "icon": "network"++ },++ "vip": {++ "name": "OpenVibe.VIP",++ "description": "Memberships and perks",++ "icon": "network"++ },++ "watch": {++ "name": "OpenVibe.Watch",++ "description": "Know the moment it changes",++ "icon": "network"++ },++ "wiki": {++ "name": "OpenVibe.Wiki",++ "description": "Knowledge, with sources",++ "icon": "network"++ },++ "work": {++ "name": "OpenVibe.Work",++ "description": "Find work, with an agent on your side",++ "icon": "network"++ }++}nnew file mode 100644BBinary files /dev/null and b/icons/space-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/space-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/space-maskable-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/space-maskable-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/tools-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/tools-512.png differnnew file mode 100644BBinary files /dev/null and b/icons/tools-maskable-192.png differnnew file mode 100644BBinary files /dev/null and b/icons/tools-maskable-512.png differ@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.0.3",++ "version": "4.1.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -16,6 +16,7 @@"./analytics/pg": "./analytics/pg.js","./analytics/retention": "./analytics/retention.js","./app-icon": "./app-icon.js",++ "./app-icons": "./app-icons.js","./assets": "./assets.js","./auth-client": "./auth-client.js","./brand": "./brand.js",@@@ -78,6 +79,7 @@"analytics/","docs/schemas/","scripts/",++ "icons/","social/","templates/","CHANGELOG.md"nnew file mode 100644@@@ -0,0 +1,62 @@++#!/usr/bin/env node++'use strict';++// Render the installable app icon sets (192/512, any + maskable) into icons/, one per app-icon tint variant++// (icons/<variant>[-maskable]-<size>.png; most sites share the network's), and write icons/sites.json — each++// service's name, tagline and the variant the share cards use (social.iconSite) — for the manifest serve.js builds.++// node scripts/build-icons.js --contracts <OpenVibe.Contracts checkout> --sharp <path to sharp module>++// sharp is not a dependency of this package, so the caller names its module. The service list is the share++// cards in social/ (the sites that already have one); the name and tagline come from each Contracts service++// manifest's `site` block, the same source build-social-cards.js reads. Re-run after a name, tagline or icon++// change, and commit the PNGs and sites.json: sites serve them from their pinned copy.++const fs = require('fs');++const path = require('path');++const icon = require('../app-icon');++const social = require('../social');++++const arg = (name, dflt) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : dflt; };++const contracts = arg('contracts');++const sharpPath = arg('sharp');++if (!contracts || !sharpPath) { console.error('usage: build-icons.js --contracts <OpenVibe.Contracts checkout> --sharp <path to sharp module>'); process.exit(1); }++const sharp = require(path.resolve(sharpPath));++++const OUT = path.join(__dirname, '..', 'icons');++const SERVICES = path.join(contracts, 'manifests', 'services');++++function serviceIds() {++ return fs.readdirSync(social.DIR).filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)).sort();++}++++function siteOf(id) {++ const m = JSON.parse(fs.readFileSync(path.join(SERVICES, `${id}.json`), 'utf8'));++ if (!m.site) throw new Error(`${id}: no site block`);++ return { id, name: m.site.name, tagline: m.site.tagline || '' };++}++++/** The product's full name: "OpenVibe." + the site name, unless it already carries its own ("OpenRestream"). */++function productName(name) {++ return /^Open(Re)(.+)$/.test(name) ? name : `OpenVibe.${name}`;++}++++(async () => {++ fs.mkdirSync(OUT, { recursive: true });++ for (const f of fs.readdirSync(OUT)) if (f.endsWith('.png')) fs.unlinkSync(path.join(OUT, f));++ const sites = {};++ const variants = new Set();++ for (const id of serviceIds()) {++ const site = siteOf(id);++ const variant = social.iconSite(id);++ sites[id] = { name: productName(site.name), description: site.tagline, icon: variant };++ variants.add(variant);++ }++ for (const variant of [...variants].sort()) {++ for (const maskable of [false, true]) {++ for (const s of [192, 512]) {++ const file = path.join(OUT, `${variant}-${maskable ? 'maskable-' : ''}${s}.png`);++ await sharp(Buffer.from(icon.svg({ site: variant, maskable })), { density: 300 }).resize(s, s).png({ compressionLevel: 9, palette: true, quality: 100 }).toFile(file);++ }++ }++ console.log(`icons/${variant}-{192,512,maskable-192,maskable-512}.png`);++ }++ fs.writeFileSync(path.join(OUT, 'sites.json'), JSON.stringify(sites, null, 2) + '\n');++ console.log(`icons/sites.json: ${Object.keys(sites).length} services`);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -11,17 +11,21 @@** A request carrying the file's current hash (?v=) is cached for a year (immutable); anything else* for five minutes. ETag = the hash (304 on a match). CORS-open and cross-origin readable, like the-- * copies on openvibe.network. Only the files openvibe-shared/files lists are served, and the share cards-- * (social/<service>.jpg, openvibe-shared/social) that og:image points at.++ * copies on openvibe.network. Only the files openvibe-shared/files lists are served, plus the share++ * cards (social/<service>.jpg, openvibe-shared/social), the installable app icons++ * (icons/<service>[-maskable]-<size>.png, openvibe-shared/app-icons) and the web app manifest built++ * per service at manifest/<service>.webmanifest.*/const fs = require('fs');const files = require('./files');const social = require('./social');++const appIcons = require('./app-icons');++const appIcon = require('./app-icon');const cache = require('./cache-policy');const { hashAsset } = require('./assets');const hashes = new Map();--const fileOf = (name) => (social.isCard(name) ? social.path(name) : files.path(name));++const fileOf = (name) => (social.isCard(name) ? social.path(name) : appIcons.isIcon(name) ? appIcons.path(name) : files.path(name));function hashOf(name) {if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(fileOf(name))));return hashes.get(name);@@@ -33,26 +37,92 @@ function url(name, base = '/shared') {return `${String(base).replace(/\/+$/, '')}/${name}?v=${hashOf(name)}`;}++const MANIFEST_RE = /^manifest\/([a-z0-9][a-z0-9-]{0,40})\.webmanifest$/;++++/** `short_name` is the part after "OpenVibe." when that fits in 12 characters; otherwise the full name. */++function shortName(name) {++ const suffix = typeof name === 'string' && name.startsWith('OpenVibe.') ? name.slice('OpenVibe.'.length) : '';++ return suffix && suffix.length <= 12 ? suffix : name;++}++++/** The web app manifest for a service, from its pre-rendered name/tagline and hashed icon URLs. */++function manifestFor(service) {++ const site = appIcons.site(service);++ if (!site || !appIcons.has(service)) return null;++ return {++ name: site.name,++ short_name: shortName(site.name),++ description: site.description || '',++ start_url: '/',++ scope: '/',++ display: 'standalone',++ background_color: appIcon.BG,++ theme_color: '#0d131d',++ icons: [++ { src: appIcons.url(service, 192), sizes: '192x192', type: 'image/png', purpose: 'any' },++ { src: appIcons.url(service, 512), sizes: '512x512', type: 'image/png', purpose: 'any' },++ { src: appIcons.url(service, 192, { maskable: true }), sizes: '192x192', type: 'image/png', purpose: 'maskable' },++ { src: appIcons.url(service, 512, { maskable: true }), sizes: '512x512', type: 'image/png', purpose: 'maskable' },++ ],++ id: '/',++ };++}++++/** `/shared/manifest/<service>.webmanifest?v=<hash>`, or null when there is no icon set for the service. */++function manifestUrl(service) {++ const data = manifestFor(service);++ if (!data) return null;++ return `/shared/manifest/${service}.webmanifest?v=${hashAsset(JSON.stringify(data))}`;++}++++function contentType(name) {++ if (name.endsWith('.css')) return 'text/css; charset=utf-8';++ if (name.endsWith('.jpg')) return 'image/jpeg';++ if (name.endsWith('.png')) return 'image/png';++ return 'application/javascript; charset=utf-8';++}++++function vOf(req) {++ return req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v');++}++++function cacheHeaders(res, name, hash, v) {++ res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: v === hash, swr: 60 }));++ res.setHeader('ETag', `"${hash}"`);++ res.setHeader('Access-Control-Allow-Origin', '*');++ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');++ res.setHeader('X-Content-Type-Options', 'nosniff');++}++/** Express/connect handler for app.use('/shared', handler()). */function handler() {return function openvibeShared(req, res, next) {if (req.method !== 'GET' && req.method !== 'HEAD') return next();const name = String(req.path || '').replace(/^\/+/, '');-- if (!files.isBrowserFile(name) && !social.isCard(name)) return next();++++ const manifest = MANIFEST_RE.exec(name);++ if (manifest) {++ const data = manifestFor(manifest[1]);++ if (!data) return next();++ const json = JSON.stringify(data);++ const hash = hashAsset(json);++ const v = vOf(req);++ res.setHeader('Content-Type', 'application/manifest+json');++ cacheHeaders(res, name, hash, v);++ if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }++ if (req.method === 'HEAD') return res.end();++ return res.end(json);++ }++++ if (!files.isBrowserFile(name) && !social.isCard(name) && !appIcons.isIcon(name)) return next();const hash = hashOf(name);-- const v = req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v');-- res.setHeader('Content-Type', name.endsWith('.css') ? 'text/css; charset=utf-8' : name.endsWith('.jpg') ? 'image/jpeg' : 'application/javascript; charset=utf-8');-- // The estate default is a day's stale-while-revalidate (cache-policy); serve.js has always used a minute,-- // because a stale shared script is the one thing a site's navbar must never run. Kept as it was.-- res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: v === hash, swr: 60 }));-- res.setHeader('ETag', `"${hash}"`);-- res.setHeader('Access-Control-Allow-Origin', '*');-- res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');-- res.setHeader('X-Content-Type-Options', 'nosniff');++ const v = vOf(req);++ res.setHeader('Content-Type', contentType(name));++ cacheHeaders(res, name, hash, v);if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }if (req.method === 'HEAD') return res.end();fs.createReadStream(fileOf(name)).on('error', next).pipe(res);};}--module.exports = { handler, url, hashOf };++module.exports = { handler, url, hashOf, manifestUrl };@@@ -29,6 +29,7 @@ const seo = require('./seo');const frame = require('./frame');const serve = require('./serve');const social = require('./social');++const appIcons = require('./app-icons');const esc = seo.esc;@@@ -78,22 +79,40 @@ function scripts(o = {}) {* and its <noscript> block at the top of <body> (facts/url/updated feed it)* facts, url, updated the `seo.pageSummary` inputs* head extra <head> markup appended verbatim (already trusted/escaped)++ * manifest the installable web app links (manifest + apple-touch-icon) are added when the++ * page's service has an icon set; `false` opts out* body the page's main content (trusted markup — the caller escaped it)* bodyAttributes attributes for <body>, escaped; bodyClass is shorthand for `class`* navLinks, home the `frame.noscriptNav` no-JavaScript navigation* footer `frame.footer` config; defaults to { service: name, variant: 'compact' }* navbar, features, routes, versions the runtime boot (see `scripts`)*/++// Most sites name their service on the navbar and footer configs rather than on the page itself.++function serviceOf(o) {++ return o.service || (o.navbar && o.navbar.service) || (o.footer && o.footer.service);++}++// A page that names no image shares its site's card (openvibe-shared/social), when the site has one and the page--// has a canonical URL to make it absolute against; the service is the page's, else its navbar's or footer's.--// `image: null` opts out.++// has a canonical URL to make it absolute against. `image: null` opts out.function defaultImage(o) {if (!o.canonical) return null;-- // Most sites name their service on the navbar and footer configs rather than on the page itself.-- const service = o.service || (o.navbar && o.navbar.service) || (o.footer && o.footer.service);++ const service = serviceOf(o);return service ? social.url(service) : null;}++// The installable web app links: the hashed manifest and the 192px touch icon. `manifest: false` opts out (a site++// that serves its own /manifest.webmanifest), and a page whose `head` already carries a manifest link keeps it.++function manifestLinks(o) {++ if (o.manifest === false) return '';++ const service = serviceOf(o);++ if (!service || !appIcons.has(service)) return '';++ if (typeof o.head === 'string' && /rel=["']?manifest/.test(o.head)) return '';++ const manifest = serve.manifestUrl(service);++ const touch = appIcons.url(service, 192);++ if (!manifest || !touch) return '';++ return `<link rel="manifest" href="${esc(manifest)}">\n<link rel="apple-touch-icon" href="${esc(touch)}">`;++}++function page(o = {}) {const name = o.name || o.service || 'OpenVibe';const title = o.title || name;@@@ -116,6 +135,7 @@ function page(o = {}) {head,summary ? summary.head : '',o.head || '',++ manifestLinks(o),scripts(o),'</head>',`<body${attrs(bodyAttributes)}>`,nnew file mode 100644@@@ -0,0 +1,129 @@++'use strict';++// openvibe-shared/app-icons: every site's installable app icon set and web app manifest, served from++// the site's pinned copy (serve.js) and linked by shell.page() with no per-site code.++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const express = require('express');++const appIcons = require('../app-icons');++const social = require('../social');++const serve = require('../serve');++const shell = require('../shell');++++/** Width and height from a PNG's IHDR chunk. */++function pngSize(buf) {++ assert.strictEqual(buf.readUInt32BE(0), 0x89504e47, 'PNG signature');++ assert.strictEqual(buf.readUInt32BE(4), 0x0d0a1a0a, 'PNG signature');++ assert.strictEqual(buf.toString('ascii', 12, 16), 'IHDR', 'IHDR is the first chunk');++ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };++}++++(async () => {++ // Every service with a share card has its four icons, valid PNGs of the right size.++ const services = fs.readdirSync(social.DIR).filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)).sort();++ assert.ok(services.length >= 30, `a card for every site (${services.length})`);++ const variants = new Set();++ for (const service of services) {++ assert.ok(appIcons.has(service), `${service} has an icon set`);++ const meta = appIcons.site(service);++ assert.ok(meta && meta.name && meta.icon === social.iconSite(service), `${service}: name and the share card's variant`);++ variants.add(meta.icon);++ }++ // One set per tint variant (most sites share the network's), not one per site: the package ships every icon.++ assert.ok(variants.size <= 10, `${variants.size} variants`);++ const pngs = fs.readdirSync(appIcons.DIR).filter((f) => f.endsWith('.png'));++ assert.strictEqual(pngs.length, variants.size * 4, 'exactly the four icons of each variant in use');++ for (const variant of variants) {++ for (const maskable of [false, true]) {++ for (const size of [192, 512]) {++ const name = `icons/${variant}-${maskable ? 'maskable-' : ''}${size}.png`;++ assert.ok(appIcons.isIcon(name), `${name} is an icon`);++ const buf = fs.readFileSync(appIcons.path(name));++ assert.deepStrictEqual(pngSize(buf), { width: size, height: size }, `${name} is ${size}×${size}`);++ }++ }++ }++ assert.strictEqual(appIcons.site('media-hub').name, 'OpenVibe.Video', 'names follow the Contracts site blocks');++ assert.strictEqual(appIcons.site('constructor'), null, 'only own entries');++++ // URL lookup mirrors social.url: hashed, immutable-shaped, null without an icon set.++ assert.match(appIcons.url('wiki', 192), /^\/shared\/icons\/network-192\.png\?v=[0-9a-f]{12}$/);++ assert.match(appIcons.url('wiki', 512, { maskable: true }), /^\/shared\/icons\/network-maskable-512\.png\?v=[0-9a-f]{12}$/);++ assert.match(appIcons.url('games', 192), /^\/shared\/icons\/games-192\.png\?v=/, 'a tinted site has its own set');++ assert.strictEqual(appIcons.url('nope', 192), null);++ assert.strictEqual(appIcons.url('wiki', 128), null);++ assert.ok(!appIcons.has('nope') && !appIcons.has('../wiki') && !appIcons.has('') && !appIcons.has(null));++ for (const bad of ['icons/network-999.png', 'icons/../serve.js', 'icons/network.jpg', 'network-192.png', 'icons/a/b-192.png', 'icons/wiki-192.png']) {++ assert.ok(!appIcons.isIcon(bad), `${bad} is not an icon`);++ }++ assert.throws(() => appIcons.path('icons/../serve.js'));++++ // serve.js answers icons and the manifest beside the browser files, content-addressed.++ const app = express();++ app.use('/shared', serve.handler());++ app.use((req, res) => res.status(404).end('nope'));++ const srv = http.createServer(app);++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${srv.address().port}`;++ try {++ let r = await fetch(base + appIcons.url('wiki', 192));++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('content-type'), 'image/png');++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable', 'the current hash is immutable');++ assert.strictEqual(Buffer.from(await r.arrayBuffer()).length, fs.statSync(appIcons.path('icons/network-192.png')).size);++ assert.strictEqual((await fetch(base + '/shared/icons/network-192.png?v=old')).headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=60', 'a stale hash is short-lived');++++ const murl = serve.manifestUrl('wiki');++ assert.match(murl, /^\/shared\/manifest\/wiki\.webmanifest\?v=[0-9a-f]{12}$/);++ r = await fetch(base + murl);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('content-type'), 'application/manifest+json');++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable');++ const manifest = await r.json();++ assert.strictEqual(manifest.name, 'OpenVibe.Wiki');++ assert.strictEqual(manifest.short_name, 'Wiki');++ assert.strictEqual(manifest.description, 'Knowledge, with sources');++ assert.strictEqual(manifest.start_url, '/');++ assert.strictEqual(manifest.scope, '/');++ assert.strictEqual(manifest.display, 'standalone');++ assert.strictEqual(manifest.background_color, '#0a0f1c');++ assert.strictEqual(manifest.theme_color, '#0d131d');++ assert.strictEqual(manifest.id, '/');++ assert.strictEqual(manifest.icons.length, 4);++ assert.deepStrictEqual([...new Set(manifest.icons.map((i) => i.purpose))].sort(), ['any', 'maskable']);++ assert.deepStrictEqual([...new Set(manifest.icons.map((i) => i.sizes))].sort(), ['192x192', '512x512']);++ for (const icon of manifest.icons) {++ assert.strictEqual(icon.type, 'image/png');++ assert.match(icon.src, /^\/shared\/icons\/network-.*\.png\?v=[0-9a-f]{12}$/);++ }++++ // OpenRestream carries its own name (no "OpenVibe." prefix), so short_name is the name itself.++ const openre = await (await fetch(base + serve.manifestUrl('openre'))).json();++ assert.strictEqual(openre.name, 'OpenRestream');++ assert.strictEqual(openre.short_name, 'OpenRestream');++++ assert.strictEqual((await fetch(base + '/shared/manifest/nope.webmanifest')).status, 404);++ assert.strictEqual((await fetch(base + '/shared/manifest/wiki.png')).status, 404);++ assert.strictEqual((await fetch(base + '/shared/manifest/%2e%2e/serve.js')).status, 404);++ assert.strictEqual((await fetch(base + '/shared/icons/nope-192.png')).status, 404);++ } finally {++ srv.close();++ }++++ // shell.page links the manifest and touch icon when the service has icons; manifest:false and an++ // existing manifest link in `head` both leave exactly what the page named.++ const html = shell.page({ service: 'wiki', name: 'OpenVibe.Wiki', canonical: 'https://openvibe.wiki/' });++ const manifestLinks = html.match(/<link rel="manifest"[^>]*>/g) || [];++ assert.strictEqual(manifestLinks.length, 1, 'exactly one manifest link');++ assert.match(manifestLinks[0], /href="\/shared\/manifest\/wiki\.webmanifest\?v=[0-9a-f]{12}">/);++ assert.match(html, /<link rel="apple-touch-icon" href="\/shared\/icons\/network-192\.png\?v=[0-9a-f]{12}">/);++ assert.ok(!/rel="manifest"/.test(shell.page({ service: 'wiki', manifest: false })), 'manifest: false opts out');++ assert.ok(!/apple-touch-icon/.test(shell.page({ service: 'wiki', manifest: false })));++ const withHead = shell.page({ service: 'wiki', head: '<link rel="manifest" href="/manifest.webmanifest">' });++ assert.strictEqual((withHead.match(/rel="manifest"/g) || []).length, 1, 'the head manifest is kept, none added');++ assert.ok(!/shared\/manifest/.test(withHead));++ assert.ok(!/rel="manifest"/.test(shell.page({ service: 'no-such-site', canonical: 'https://x.example/' })), 'an unknown service adds none');++ assert.match(shell.page({ name: 'OpenVibe.Watch', canonical: 'https://openvibe.watch/', navbar: { service: 'watch' } }), /shared\/manifest\/watch\.webmanifest\?v=/, 'the navbar names the service');++++ console.log('app-icons: ok');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -4,6 +4,23 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,23 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.1.0 — 2026-10-12
+
+- **Every `shell.page()` site is an installable web app, with no per-site code.** A new `icons/` directory ships
+ four pre-rendered app icons (192 and 512, any and maskable) per app-icon tint variant — 8 sets, 32 palette PNGs,
+ about 630 KB, since most sites share the network's tint — drawn by `scripts/build-icons.js` from
+ `app-icon.svg({ site: variant, maskable })`; `icons/sites.json` gives each of the 35 services with a share card its
+ name, tagline and variant (`social.iconSite`). The new `openvibe-shared/app-icons` module names them (`has`, `url`,
+ `site`, `isIcon`, `path`), and `serve.js` answers them at `/shared/icons/<variant>[-maskable]-<size>.png` beside the
+ browser files and share cards.
+- `serve.js` now also answers `GET /shared/manifest/<service>.webmanifest` as `application/manifest+json`, built
+ from the service's Contracts `site` block (name and tagline, committed in `icons/sites.json`) with the same
+ hashed icon URLs, `start_url`/`scope` `/`, `display: standalone`, the app-icon background and the navbar's
+ `#0d131d` theme colour. Unknown services 404.
+- `shell.page()` adds `<link rel="manifest">` and `<link rel="apple-touch-icon">` for the page's service when it has
+ icons, using the same service resolution as the share-card default (`service`, else `navbar.service`, else
+ `footer.service`). `manifest: false` opts out, and a page whose `head` already carries a manifest link keeps it.
+
## 4.0.3 — 2026-10-11
## 4.0.3 — 2026-10-11
- Share cards: `run` (openvibe.run has a site block since openvibe-contracts 0.141.0) and a redrawn `host` (its tagline
- Share cards: `run` (openvibe.run has a site block since openvibe-contracts 0.141.0) and a redrawn `host` (its tagline
@@ -20,9 +20,9 @@ were reconciled first.
@@ -20,9 +20,9 @@ were reconciled first.
| Kind | Files |
| Kind | Files |
|---|---|
|---|---|
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
-| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
+| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `app-icons` (every site's installable app icon set: `has(service)`, `url(service, size, { maskable })`, `isIcon(name)`, `path(name)`; the pre-rendered PNGs are served at `/shared/icons/<service>[-maskable]-<size>.png` and named by the web app manifest `serve.js` serves at `/shared/manifest/<service>.webmanifest`), `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
-| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) |
+| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js`, `scripts/build-icons.js`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) |
| Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |
| Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |
| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once), `scripts/docs-status.js` (`node scripts/docs-status.js <dir> [--check]`: STATUS.json's contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the pinned releases; `--check` exits 1 on drift) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once), `scripts/docs-status.js` (`node scripts/docs-status.js <dir> [--check]`: STATUS.json's contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the pinned releases; `--check` exits 1 on drift) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
@@ -561,6 +561,16 @@ A server-rendered page puts these tags in its `<head>` itself, with the SEO head
@@ -561,6 +561,16 @@ A server-rendered page puts these tags in its `<head>` itself, with the SEO head
Frame around them: `openvibe-shared/shell` (`shell.page(o)`) composes exactly that — see "The page
Frame around them: `openvibe-shared/shell` (`shell.page(o)`) composes exactly that — see "The page
shell" below.
shell" below.
+### Installable sites
+
+Every page `shell.page()` renders is also an installable web app, with no per-site code. When the page's
+service has a pre-rendered icon set (`openvibe-shared/app-icons`; `icons/<variant>[-maskable]-192|512.png`, one set per tint variant),
+the shell adds `<link rel="manifest" href="/shared/manifest/<service>.webmanifest?v=<hash>">` and an
+`<link rel="apple-touch-icon">` to its `<head>`. `serve.js` answers both from the site's pinned copy — the
+manifest is built from the service's Contracts `site` block (name and tagline) with the hashed icon URLs —
+so a home-screen install never depends on another host. `manifest: false` opts out for sites that serve
+their own `/manifest.webmanifest`, and a page whose `head` already has a manifest link keeps it.
+
### The page shell (`shell.js`)
### The page shell (`shell.js`)
```text
```text
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// openvibe-shared/app-icons — every OpenVibe site's installable app icon set.
+//
+// The icons are pre-rendered PNGs in icons/<variant>[-maskable]-<size>.png (192 and 512), one set per app-icon
+// tint variant (most sites share the network's), drawn by scripts/build-icons.js from app-icon.svg({ site: variant,
+// maskable }); icons/sites.json names each service's variant (social.iconSite). A site serves its
+// pinned copy at /shared/icons/<name> through openvibe-shared/serve, and the web app manifest served at
+// /shared/manifest/<service>.webmanifest points at the same hashed URLs. (The `icons` subpath is already the
+// ring-icon module `ov-icons.js`, so the per-site icon set lives here.)
+//
+// appIcons.has(service) is there an icon set for this service id
+// appIcons.url(service, size, { maskable }) '/shared/icons/<variant>[-maskable]-<size>.png?v=<hash>', or null
+// appIcons.site(service) { name, description, icon } from icons/sites.json, or null
+// appIcons.isIcon(name) 'icons/<variant>[-maskable]-<size>.png' names serve.js may answer
+// appIcons.path(name) the file on disk (throws for anything that is not an icon)
+//
+// shell.page() uses appIcons.url(service, 192) for the apple-touch-icon when the page names its service.
+// ═══════════════════════════════════════════════════════════════
+const fs = require('fs');
+const path = require('path');
+const { hashAsset } = require('./assets');
+
+const DIR = path.join(__dirname, 'icons');
+const NAME_RE = /^icons\/([a-z0-9][a-z0-9-]{0,40})-(maskable-)?(192|512)\.png$/;
+const ID_RE = /^[a-z0-9][a-z0-9-]{0,40}$/;
+const hashes = new Map();
+let sites = null;
+/** icons/sites.json: { <service>: { name, description, icon: <variant> } }, read once. */
+function site(service) {
+ if (!sites) { try { sites = JSON.parse(fs.readFileSync(path.join(DIR, 'sites.json'), 'utf8')); } catch { sites = {}; } }
+ const id = String(service || '');
+ return ID_RE.test(id) && Object.prototype.hasOwnProperty.call(sites, id) ? sites[id] : null;
+}
+
+function isIcon(name) {
+ const m = NAME_RE.exec(String(name || ''));
+ return Boolean(m) && fs.existsSync(path.join(DIR, `${m[1]}-${m[2] || ''}${m[3]}.png`));
+}
+
+function iconPath(name) {
+ if (!isIcon(name)) throw new Error(`openvibe-shared/app-icons: ${name} is not an app icon`);
+ return path.join(__dirname, name);
+}
+
+const has = (service) => { const s = site(service); return Boolean(s && s.icon && isIcon(`icons/${s.icon}-192.png`)); };
+
+function url(service, size, { maskable = false } = {}) {
+ const meta = site(service);
+ if (!meta || !meta.icon) return null;
+ const s = Number(size);
+ if (s !== 192 && s !== 512) return null;
+ const name = `icons/${meta.icon}-${maskable ? 'maskable-' : ''}${s}.png`;
+ if (!isIcon(name)) return null;
+ if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(iconPath(name))));
+ return `/shared/${name}?v=${hashes.get(name)}`;
+}
+
+module.exports = { has, url, site, isIcon, path: iconPath, DIR };
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/ai-192.png differ
Binary files /dev/null and b/icons/ai-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/ai-512.png differ
Binary files /dev/null and b/icons/ai-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/ai-maskable-192.png differ
Binary files /dev/null and b/icons/ai-maskable-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/ai-maskable-512.png differ
Binary files /dev/null and b/icons/ai-maskable-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/community-192.png differ
Binary files /dev/null and b/icons/community-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/community-512.png differ
Binary files /dev/null and b/icons/community-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/community-maskable-192.png differ
Binary files /dev/null and b/icons/community-maskable-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/community-maskable-512.png differ
Binary files /dev/null and b/icons/community-maskable-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/games-192.png differ
Binary files /dev/null and b/icons/games-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/games-512.png differ
Binary files /dev/null and b/icons/games-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/games-maskable-192.png differ
Binary files /dev/null and b/icons/games-maskable-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/games-maskable-512.png differ
Binary files /dev/null and b/icons/games-maskable-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/live-192.png differ
Binary files /dev/null and b/icons/live-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/live-512.png differ
Binary files /dev/null and b/icons/live-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/live-maskable-192.png differ
Binary files /dev/null and b/icons/live-maskable-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/live-maskable-512.png differ
Binary files /dev/null and b/icons/live-maskable-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/media-192.png differ
Binary files /dev/null and b/icons/media-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/media-512.png differ
Binary files /dev/null and b/icons/media-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/media-maskable-192.png differ
Binary files /dev/null and b/icons/media-maskable-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/media-maskable-512.png differ
Binary files /dev/null and b/icons/media-maskable-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/network-192.png differ
Binary files /dev/null and b/icons/network-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/network-512.png differ
Binary files /dev/null and b/icons/network-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/network-maskable-192.png differ
Binary files /dev/null and b/icons/network-maskable-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/network-maskable-512.png differ
Binary files /dev/null and b/icons/network-maskable-512.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,177 @@
@@ -0,0 +1,177 @@
+{
+ "actor": {
+ "name": "OpenVibe.Actor",
+ "description": "Your agent for everything",
+ "icon": "network"
+ },
+ "ai": {
+ "name": "OpenVibe.AI",
+ "description": "Many models, one API. Every answer explained.",
+ "icon": "ai"
+ },
+ "blog": {
+ "name": "OpenVibe.Blog",
+ "description": "Long-form, yours to keep",
+ "icon": "network"
+ },
+ "bot": {
+ "name": "OpenVibe.Bot",
+ "description": "An open control panel for robots",
+ "icon": "network"
+ },
+ "chat": {
+ "name": "OpenVibe.Chat",
+ "description": "One chat for the network",
+ "icon": "network"
+ },
+ "codes": {
+ "name": "OpenVibe.Codes",
+ "description": "Code with any agent",
+ "icon": "network"
+ },
+ "community": {
+ "name": "OpenVibe.Community",
+ "description": "Pastes and posts",
+ "icon": "community"
+ },
+ "coupons": {
+ "name": "OpenVibe.Coupons",
+ "description": "Codes that work",
+ "icon": "network"
+ },
+ "deals": {
+ "name": "OpenVibe.Deals",
+ "description": "Deals worth sharing",
+ "icon": "network"
+ },
+ "events": {
+ "name": "OpenVibe.Events",
+ "description": "Events your services can count on.",
+ "icon": "network"
+ },
+ "food": {
+ "name": "OpenVibe.Food",
+ "description": "What should we eat?",
+ "icon": "network"
+ },
+ "games": {
+ "name": "OpenVibe.Games",
+ "description": "Browser games",
+ "icon": "games"
+ },
+ "help": {
+ "name": "OpenVibe.Help",
+ "description": "Answers for every OpenVibe site",
+ "icon": "network"
+ },
+ "host": {
+ "name": "OpenVibe.Host",
+ "description": "Hosting, compared honestly",
+ "icon": "network"
+ },
+ "inventory": {
+ "name": "OpenVibe.Inventory",
+ "description": "Your items, on every OpenVibe site.",
+ "icon": "network"
+ },
+ "live": {
+ "name": "OpenVibe.Live",
+ "description": "Streams, clips and chat",
+ "icon": "live"
+ },
+ "media": {
+ "name": "OpenVibe.Media",
+ "description": "VODs, clips, files",
+ "icon": "media"
+ },
+ "media-hub": {
+ "name": "OpenVibe.Video",
+ "description": "Past streams, clips and your drive",
+ "icon": "media"
+ },
+ "network": {
+ "name": "OpenVibe.Network",
+ "description": "Account and themes",
+ "icon": "network"
+ },
+ "news": {
+ "name": "OpenVibe.News",
+ "description": "Sourced, not spun",
+ "icon": "network"
+ },
+ "openre": {
+ "name": "OpenRestream",
+ "description": "Go live once, stream everywhere",
+ "icon": "network"
+ },
+ "quest": {
+ "name": "OpenVibe.Quest",
+ "description": "Badges for what you really do",
+ "icon": "network"
+ },
+ "rent": {
+ "name": "OpenVibe.Rent",
+ "description": "Find a place, or rent yours out",
+ "icon": "network"
+ },
+ "reviews": {
+ "name": "OpenVibe.Reviews",
+ "description": "Reviews with receipts",
+ "icon": "network"
+ },
+ "run": {
+ "name": "OpenVibe.Run",
+ "description": "Run a job on a node that can",
+ "icon": "network"
+ },
+ "search": {
+ "name": "OpenVibe.Search",
+ "description": "Search the OpenVibe network.",
+ "icon": "network"
+ },
+ "services": {
+ "name": "OpenVibe.Services",
+ "description": "Build on OpenVibe",
+ "icon": "network"
+ },
+ "space": {
+ "name": "OpenVibe.Space",
+ "description": "Static sites, hosted by OpenVibe",
+ "icon": "space"
+ },
+ "tips": {
+ "name": "OpenVibe.Tips",
+ "description": "Support creators",
+ "icon": "network"
+ },
+ "tools": {
+ "name": "OpenVibe.Tools",
+ "description": "Every online tool",
+ "icon": "tools"
+ },
+ "trade": {
+ "name": "OpenVibe.Trade",
+ "description": "Watchlists and alerts",
+ "icon": "network"
+ },
+ "vip": {
+ "name": "OpenVibe.VIP",
+ "description": "Memberships and perks",
+ "icon": "network"
+ },
+ "watch": {
+ "name": "OpenVibe.Watch",
+ "description": "Know the moment it changes",
+ "icon": "network"
+ },
+ "wiki": {
+ "name": "OpenVibe.Wiki",
+ "description": "Knowledge, with sources",
+ "icon": "network"
+ },
+ "work": {
+ "name": "OpenVibe.Work",
+ "description": "Find work, with an agent on your side",
+ "icon": "network"
+ }
+}
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/space-192.png differ
Binary files /dev/null and b/icons/space-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/space-512.png differ
Binary files /dev/null and b/icons/space-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/space-maskable-192.png differ
Binary files /dev/null and b/icons/space-maskable-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/space-maskable-512.png differ
Binary files /dev/null and b/icons/space-maskable-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/tools-192.png differ
Binary files /dev/null and b/icons/tools-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/tools-512.png differ
Binary files /dev/null and b/icons/tools-512.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/tools-maskable-192.png differ
Binary files /dev/null and b/icons/tools-maskable-192.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/icons/tools-maskable-512.png differ
Binary files /dev/null and b/icons/tools-maskable-512.png differ
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.0.3",
+ "version": "4.1.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
"./analytics/pg": "./analytics/pg.js",
"./analytics/pg": "./analytics/pg.js",
"./analytics/retention": "./analytics/retention.js",
"./analytics/retention": "./analytics/retention.js",
"./app-icon": "./app-icon.js",
"./app-icon": "./app-icon.js",
+ "./app-icons": "./app-icons.js",
"./assets": "./assets.js",
"./assets": "./assets.js",
"./auth-client": "./auth-client.js",
"./auth-client": "./auth-client.js",
"./brand": "./brand.js",
"./brand": "./brand.js",
@@ -78,6 +79,7 @@
@@ -78,6 +79,7 @@
"analytics/",
"analytics/",
"docs/schemas/",
"docs/schemas/",
"scripts/",
"scripts/",
+ "icons/",
"social/",
"social/",
"templates/",
"templates/",
"CHANGELOG.md"
"CHANGELOG.md"
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+'use strict';
+// Render the installable app icon sets (192/512, any + maskable) into icons/, one per app-icon tint variant
+// (icons/<variant>[-maskable]-<size>.png; most sites share the network's), and write icons/sites.json — each
+// service's name, tagline and the variant the share cards use (social.iconSite) — for the manifest serve.js builds.
+// node scripts/build-icons.js --contracts <OpenVibe.Contracts checkout> --sharp <path to sharp module>
+// sharp is not a dependency of this package, so the caller names its module. The service list is the share
+// cards in social/ (the sites that already have one); the name and tagline come from each Contracts service
+// manifest's `site` block, the same source build-social-cards.js reads. Re-run after a name, tagline or icon
+// change, and commit the PNGs and sites.json: sites serve them from their pinned copy.
+const fs = require('fs');
+const path = require('path');
+const icon = require('../app-icon');
+const social = require('../social');
+
+const arg = (name, dflt) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : dflt; };
+const contracts = arg('contracts');
+const sharpPath = arg('sharp');
+if (!contracts || !sharpPath) { console.error('usage: build-icons.js --contracts <OpenVibe.Contracts checkout> --sharp <path to sharp module>'); process.exit(1); }
+const sharp = require(path.resolve(sharpPath));
+
+const OUT = path.join(__dirname, '..', 'icons');
+const SERVICES = path.join(contracts, 'manifests', 'services');
+
+function serviceIds() {
+ return fs.readdirSync(social.DIR).filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)).sort();
+}
+
+function siteOf(id) {
+ const m = JSON.parse(fs.readFileSync(path.join(SERVICES, `${id}.json`), 'utf8'));
+ if (!m.site) throw new Error(`${id}: no site block`);
+ return { id, name: m.site.name, tagline: m.site.tagline || '' };
+}
+
+/** The product's full name: "OpenVibe." + the site name, unless it already carries its own ("OpenRestream"). */
+function productName(name) {
+ return /^Open(Re)(.+)$/.test(name) ? name : `OpenVibe.${name}`;
+}
+
+(async () => {
+ fs.mkdirSync(OUT, { recursive: true });
+ for (const f of fs.readdirSync(OUT)) if (f.endsWith('.png')) fs.unlinkSync(path.join(OUT, f));
+ const sites = {};
+ const variants = new Set();
+ for (const id of serviceIds()) {
+ const site = siteOf(id);
+ const variant = social.iconSite(id);
+ sites[id] = { name: productName(site.name), description: site.tagline, icon: variant };
+ variants.add(variant);
+ }
+ for (const variant of [...variants].sort()) {
+ for (const maskable of [false, true]) {
+ for (const s of [192, 512]) {
+ const file = path.join(OUT, `${variant}-${maskable ? 'maskable-' : ''}${s}.png`);
+ await sharp(Buffer.from(icon.svg({ site: variant, maskable })), { density: 300 }).resize(s, s).png({ compressionLevel: 9, palette: true, quality: 100 }).toFile(file);
+ }
+ }
+ console.log(`icons/${variant}-{192,512,maskable-192,maskable-512}.png`);
+ }
+ fs.writeFileSync(path.join(OUT, 'sites.json'), JSON.stringify(sites, null, 2) + '\n');
+ console.log(`icons/sites.json: ${Object.keys(sites).length} services`);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -11,17 +11,21 @@
@@ -11,17 +11,21 @@
*
*
* A request carrying the file's current hash (?v=) is cached for a year (immutable); anything else
* A request carrying the file's current hash (?v=) is cached for a year (immutable); anything else
* for five minutes. ETag = the hash (304 on a match). CORS-open and cross-origin readable, like the
* for five minutes. ETag = the hash (304 on a match). CORS-open and cross-origin readable, like the
- * copies on openvibe.network. Only the files openvibe-shared/files lists are served, and the share cards
- * (social/<service>.jpg, openvibe-shared/social) that og:image points at.
+ * copies on openvibe.network. Only the files openvibe-shared/files lists are served, plus the share
+ * cards (social/<service>.jpg, openvibe-shared/social), the installable app icons
+ * (icons/<service>[-maskable]-<size>.png, openvibe-shared/app-icons) and the web app manifest built
+ * per service at manifest/<service>.webmanifest.
*/
*/
const fs = require('fs');
const fs = require('fs');
const files = require('./files');
const files = require('./files');
const social = require('./social');
const social = require('./social');
+const appIcons = require('./app-icons');
+const appIcon = require('./app-icon');
const cache = require('./cache-policy');
const cache = require('./cache-policy');
const { hashAsset } = require('./assets');
const { hashAsset } = require('./assets');
const hashes = new Map();
const hashes = new Map();
-const fileOf = (name) => (social.isCard(name) ? social.path(name) : files.path(name));
+const fileOf = (name) => (social.isCard(name) ? social.path(name) : appIcons.isIcon(name) ? appIcons.path(name) : files.path(name));
function hashOf(name) {
function hashOf(name) {
if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(fileOf(name))));
if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(fileOf(name))));
return hashes.get(name);
return hashes.get(name);
@@ -33,26 +37,92 @@ function url(name, base = '/shared') {
@@ -33,26 +37,92 @@ function url(name, base = '/shared') {
return `${String(base).replace(/\/+$/, '')}/${name}?v=${hashOf(name)}`;
return `${String(base).replace(/\/+$/, '')}/${name}?v=${hashOf(name)}`;
}
}
+const MANIFEST_RE = /^manifest\/([a-z0-9][a-z0-9-]{0,40})\.webmanifest$/;
+
+/** `short_name` is the part after "OpenVibe." when that fits in 12 characters; otherwise the full name. */
+function shortName(name) {
+ const suffix = typeof name === 'string' && name.startsWith('OpenVibe.') ? name.slice('OpenVibe.'.length) : '';
+ return suffix && suffix.length <= 12 ? suffix : name;
+}
+
+/** The web app manifest for a service, from its pre-rendered name/tagline and hashed icon URLs. */
+function manifestFor(service) {
+ const site = appIcons.site(service);
+ if (!site || !appIcons.has(service)) return null;
+ return {
+ name: site.name,
+ short_name: shortName(site.name),
+ description: site.description || '',
+ start_url: '/',
+ scope: '/',
+ display: 'standalone',
+ background_color: appIcon.BG,
+ theme_color: '#0d131d',
+ icons: [
+ { src: appIcons.url(service, 192), sizes: '192x192', type: 'image/png', purpose: 'any' },
+ { src: appIcons.url(service, 512), sizes: '512x512', type: 'image/png', purpose: 'any' },
+ { src: appIcons.url(service, 192, { maskable: true }), sizes: '192x192', type: 'image/png', purpose: 'maskable' },
+ { src: appIcons.url(service, 512, { maskable: true }), sizes: '512x512', type: 'image/png', purpose: 'maskable' },
+ ],
+ id: '/',
+ };
+}
+
+/** `/shared/manifest/<service>.webmanifest?v=<hash>`, or null when there is no icon set for the service. */
+function manifestUrl(service) {
+ const data = manifestFor(service);
+ if (!data) return null;
+ return `/shared/manifest/${service}.webmanifest?v=${hashAsset(JSON.stringify(data))}`;
+}
+
+function contentType(name) {
+ if (name.endsWith('.css')) return 'text/css; charset=utf-8';
+ if (name.endsWith('.jpg')) return 'image/jpeg';
+ if (name.endsWith('.png')) return 'image/png';
+ return 'application/javascript; charset=utf-8';
+}
+
+function vOf(req) {
+ return req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v');
+}
+
+function cacheHeaders(res, name, hash, v) {
+ res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: v === hash, swr: 60 }));
+ res.setHeader('ETag', `"${hash}"`);
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+}
+
/** Express/connect handler for app.use('/shared', handler()). */
/** Express/connect handler for app.use('/shared', handler()). */
function handler() {
function handler() {
return function openvibeShared(req, res, next) {
return function openvibeShared(req, res, next) {
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
const name = String(req.path || '').replace(/^\/+/, '');
const name = String(req.path || '').replace(/^\/+/, '');
- if (!files.isBrowserFile(name) && !social.isCard(name)) return next();
+
+ const manifest = MANIFEST_RE.exec(name);
+ if (manifest) {
+ const data = manifestFor(manifest[1]);
+ if (!data) return next();
+ const json = JSON.stringify(data);
+ const hash = hashAsset(json);
+ const v = vOf(req);
+ res.setHeader('Content-Type', 'application/manifest+json');
+ cacheHeaders(res, name, hash, v);
+ if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }
+ if (req.method === 'HEAD') return res.end();
+ return res.end(json);
+ }
+
+ if (!files.isBrowserFile(name) && !social.isCard(name) && !appIcons.isIcon(name)) return next();
const hash = hashOf(name);
const hash = hashOf(name);
- const v = req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v');
- res.setHeader('Content-Type', name.endsWith('.css') ? 'text/css; charset=utf-8' : name.endsWith('.jpg') ? 'image/jpeg' : 'application/javascript; charset=utf-8');
- // The estate default is a day's stale-while-revalidate (cache-policy); serve.js has always used a minute,
- // because a stale shared script is the one thing a site's navbar must never run. Kept as it was.
- res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: v === hash, swr: 60 }));
- res.setHeader('ETag', `"${hash}"`);
- res.setHeader('Access-Control-Allow-Origin', '*');
- res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
- res.setHeader('X-Content-Type-Options', 'nosniff');
+ const v = vOf(req);
+ res.setHeader('Content-Type', contentType(name));
+ cacheHeaders(res, name, hash, v);
if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }
if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }
if (req.method === 'HEAD') return res.end();
if (req.method === 'HEAD') return res.end();
fs.createReadStream(fileOf(name)).on('error', next).pipe(res);
fs.createReadStream(fileOf(name)).on('error', next).pipe(res);
};
};
}
}
-module.exports = { handler, url, hashOf };
+module.exports = { handler, url, hashOf, manifestUrl };
@@ -29,6 +29,7 @@ const seo = require('./seo');
@@ -29,6 +29,7 @@ const seo = require('./seo');
const frame = require('./frame');
const frame = require('./frame');
const serve = require('./serve');
const serve = require('./serve');
const social = require('./social');
const social = require('./social');
+const appIcons = require('./app-icons');
const esc = seo.esc;
const esc = seo.esc;
@@ -78,22 +79,40 @@ function scripts(o = {}) {
@@ -78,22 +79,40 @@ function scripts(o = {}) {
* and its <noscript> block at the top of <body> (facts/url/updated feed it)
* and its <noscript> block at the top of <body> (facts/url/updated feed it)
* facts, url, updated the `seo.pageSummary` inputs
* facts, url, updated the `seo.pageSummary` inputs
* head extra <head> markup appended verbatim (already trusted/escaped)
* head extra <head> markup appended verbatim (already trusted/escaped)
+ * manifest the installable web app links (manifest + apple-touch-icon) are added when the
+ * page's service has an icon set; `false` opts out
* body the page's main content (trusted markup — the caller escaped it)
* body the page's main content (trusted markup — the caller escaped it)
* bodyAttributes attributes for <body>, escaped; bodyClass is shorthand for `class`
* bodyAttributes attributes for <body>, escaped; bodyClass is shorthand for `class`
* navLinks, home the `frame.noscriptNav` no-JavaScript navigation
* navLinks, home the `frame.noscriptNav` no-JavaScript navigation
* footer `frame.footer` config; defaults to { service: name, variant: 'compact' }
* footer `frame.footer` config; defaults to { service: name, variant: 'compact' }
* navbar, features, routes, versions the runtime boot (see `scripts`)
* navbar, features, routes, versions the runtime boot (see `scripts`)
*/
*/
+// Most sites name their service on the navbar and footer configs rather than on the page itself.
+function serviceOf(o) {
+ return o.service || (o.navbar && o.navbar.service) || (o.footer && o.footer.service);
+}
+
// A page that names no image shares its site's card (openvibe-shared/social), when the site has one and the page
// A page that names no image shares its site's card (openvibe-shared/social), when the site has one and the page
-// has a canonical URL to make it absolute against; the service is the page's, else its navbar's or footer's.
-// `image: null` opts out.
+// has a canonical URL to make it absolute against. `image: null` opts out.
function defaultImage(o) {
function defaultImage(o) {
if (!o.canonical) return null;
if (!o.canonical) return null;
- // Most sites name their service on the navbar and footer configs rather than on the page itself.
- const service = o.service || (o.navbar && o.navbar.service) || (o.footer && o.footer.service);
+ const service = serviceOf(o);
return service ? social.url(service) : null;
return service ? social.url(service) : null;
}
}
+// The installable web app links: the hashed manifest and the 192px touch icon. `manifest: false` opts out (a site
+// that serves its own /manifest.webmanifest), and a page whose `head` already carries a manifest link keeps it.
+function manifestLinks(o) {
+ if (o.manifest === false) return '';
+ const service = serviceOf(o);
+ if (!service || !appIcons.has(service)) return '';
+ if (typeof o.head === 'string' && /rel=["']?manifest/.test(o.head)) return '';
+ const manifest = serve.manifestUrl(service);
+ const touch = appIcons.url(service, 192);
+ if (!manifest || !touch) return '';
+ return `<link rel="manifest" href="${esc(manifest)}">\n<link rel="apple-touch-icon" href="${esc(touch)}">`;
+}
+
function page(o = {}) {
function page(o = {}) {
const name = o.name || o.service || 'OpenVibe';
const name = o.name || o.service || 'OpenVibe';
const title = o.title || name;
const title = o.title || name;
@@ -116,6 +135,7 @@ function page(o = {}) {
@@ -116,6 +135,7 @@ function page(o = {}) {
head,
head,
summary ? summary.head : '',
summary ? summary.head : '',
o.head || '',
o.head || '',
+ manifestLinks(o),
scripts(o),
scripts(o),
'</head>',
'</head>',
`<body${attrs(bodyAttributes)}>`,
`<body${attrs(bodyAttributes)}>`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+'use strict';
+// openvibe-shared/app-icons: every site's installable app icon set and web app manifest, served from
+// the site's pinned copy (serve.js) and linked by shell.page() with no per-site code.
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const express = require('express');
+const appIcons = require('../app-icons');
+const social = require('../social');
+const serve = require('../serve');
+const shell = require('../shell');
+
+/** Width and height from a PNG's IHDR chunk. */
+function pngSize(buf) {
+ assert.strictEqual(buf.readUInt32BE(0), 0x89504e47, 'PNG signature');
+ assert.strictEqual(buf.readUInt32BE(4), 0x0d0a1a0a, 'PNG signature');
+ assert.strictEqual(buf.toString('ascii', 12, 16), 'IHDR', 'IHDR is the first chunk');
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
+}
+
+(async () => {
+ // Every service with a share card has its four icons, valid PNGs of the right size.
+ const services = fs.readdirSync(social.DIR).filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)).sort();
+ assert.ok(services.length >= 30, `a card for every site (${services.length})`);
+ const variants = new Set();
+ for (const service of services) {
+ assert.ok(appIcons.has(service), `${service} has an icon set`);
+ const meta = appIcons.site(service);
+ assert.ok(meta && meta.name && meta.icon === social.iconSite(service), `${service}: name and the share card's variant`);
+ variants.add(meta.icon);
+ }
+ // One set per tint variant (most sites share the network's), not one per site: the package ships every icon.
+ assert.ok(variants.size <= 10, `${variants.size} variants`);
+ const pngs = fs.readdirSync(appIcons.DIR).filter((f) => f.endsWith('.png'));
+ assert.strictEqual(pngs.length, variants.size * 4, 'exactly the four icons of each variant in use');
+ for (const variant of variants) {
+ for (const maskable of [false, true]) {
+ for (const size of [192, 512]) {
+ const name = `icons/${variant}-${maskable ? 'maskable-' : ''}${size}.png`;
+ assert.ok(appIcons.isIcon(name), `${name} is an icon`);
+ const buf = fs.readFileSync(appIcons.path(name));
+ assert.deepStrictEqual(pngSize(buf), { width: size, height: size }, `${name} is ${size}×${size}`);
+ }
+ }
+ }
+ assert.strictEqual(appIcons.site('media-hub').name, 'OpenVibe.Video', 'names follow the Contracts site blocks');
+ assert.strictEqual(appIcons.site('constructor'), null, 'only own entries');
+
+ // URL lookup mirrors social.url: hashed, immutable-shaped, null without an icon set.
+ assert.match(appIcons.url('wiki', 192), /^\/shared\/icons\/network-192\.png\?v=[0-9a-f]{12}$/);
+ assert.match(appIcons.url('wiki', 512, { maskable: true }), /^\/shared\/icons\/network-maskable-512\.png\?v=[0-9a-f]{12}$/);
+ assert.match(appIcons.url('games', 192), /^\/shared\/icons\/games-192\.png\?v=/, 'a tinted site has its own set');
+ assert.strictEqual(appIcons.url('nope', 192), null);
+ assert.strictEqual(appIcons.url('wiki', 128), null);
+ assert.ok(!appIcons.has('nope') && !appIcons.has('../wiki') && !appIcons.has('') && !appIcons.has(null));
+ for (const bad of ['icons/network-999.png', 'icons/../serve.js', 'icons/network.jpg', 'network-192.png', 'icons/a/b-192.png', 'icons/wiki-192.png']) {
+ assert.ok(!appIcons.isIcon(bad), `${bad} is not an icon`);
+ }
+ assert.throws(() => appIcons.path('icons/../serve.js'));
+
+ // serve.js answers icons and the manifest beside the browser files, content-addressed.
+ const app = express();
+ app.use('/shared', serve.handler());
+ app.use((req, res) => res.status(404).end('nope'));
+ const srv = http.createServer(app);
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${srv.address().port}`;
+ try {
+ let r = await fetch(base + appIcons.url('wiki', 192));
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('content-type'), 'image/png');
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable', 'the current hash is immutable');
+ assert.strictEqual(Buffer.from(await r.arrayBuffer()).length, fs.statSync(appIcons.path('icons/network-192.png')).size);
+ assert.strictEqual((await fetch(base + '/shared/icons/network-192.png?v=old')).headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=60', 'a stale hash is short-lived');
+
+ const murl = serve.manifestUrl('wiki');
+ assert.match(murl, /^\/shared\/manifest\/wiki\.webmanifest\?v=[0-9a-f]{12}$/);
+ r = await fetch(base + murl);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('content-type'), 'application/manifest+json');
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable');
+ const manifest = await r.json();
+ assert.strictEqual(manifest.name, 'OpenVibe.Wiki');
+ assert.strictEqual(manifest.short_name, 'Wiki');
+ assert.strictEqual(manifest.description, 'Knowledge, with sources');
+ assert.strictEqual(manifest.start_url, '/');
+ assert.strictEqual(manifest.scope, '/');
+ assert.strictEqual(manifest.display, 'standalone');
+ assert.strictEqual(manifest.background_color, '#0a0f1c');
+ assert.strictEqual(manifest.theme_color, '#0d131d');
+ assert.strictEqual(manifest.id, '/');
+ assert.strictEqual(manifest.icons.length, 4);
+ assert.deepStrictEqual([...new Set(manifest.icons.map((i) => i.purpose))].sort(), ['any', 'maskable']);
+ assert.deepStrictEqual([...new Set(manifest.icons.map((i) => i.sizes))].sort(), ['192x192', '512x512']);
+ for (const icon of manifest.icons) {
+ assert.strictEqual(icon.type, 'image/png');
+ assert.match(icon.src, /^\/shared\/icons\/network-.*\.png\?v=[0-9a-f]{12}$/);
+ }
+
+ // OpenRestream carries its own name (no "OpenVibe." prefix), so short_name is the name itself.
+ const openre = await (await fetch(base + serve.manifestUrl('openre'))).json();
+ assert.strictEqual(openre.name, 'OpenRestream');
+ assert.strictEqual(openre.short_name, 'OpenRestream');
+
+ assert.strictEqual((await fetch(base + '/shared/manifest/nope.webmanifest')).status, 404);
+ assert.strictEqual((await fetch(base + '/shared/manifest/wiki.png')).status, 404);
+ assert.strictEqual((await fetch(base + '/shared/manifest/%2e%2e/serve.js')).status, 404);
+ assert.strictEqual((await fetch(base + '/shared/icons/nope-192.png')).status, 404);
+ } finally {
+ srv.close();
+ }
+
+ // shell.page links the manifest and touch icon when the service has icons; manifest:false and an
+ // existing manifest link in `head` both leave exactly what the page named.
+ const html = shell.page({ service: 'wiki', name: 'OpenVibe.Wiki', canonical: 'https://openvibe.wiki/' });
+ const manifestLinks = html.match(/<link rel="manifest"[^>]*>/g) || [];
+ assert.strictEqual(manifestLinks.length, 1, 'exactly one manifest link');
+ assert.match(manifestLinks[0], /href="\/shared\/manifest\/wiki\.webmanifest\?v=[0-9a-f]{12}">/);
+ assert.match(html, /<link rel="apple-touch-icon" href="\/shared\/icons\/network-192\.png\?v=[0-9a-f]{12}">/);
+ assert.ok(!/rel="manifest"/.test(shell.page({ service: 'wiki', manifest: false })), 'manifest: false opts out');
+ assert.ok(!/apple-touch-icon/.test(shell.page({ service: 'wiki', manifest: false })));
+ const withHead = shell.page({ service: 'wiki', head: '<link rel="manifest" href="/manifest.webmanifest">' });
+ assert.strictEqual((withHead.match(/rel="manifest"/g) || []).length, 1, 'the head manifest is kept, none added');
+ assert.ok(!/shared\/manifest/.test(withHead));
+ assert.ok(!/rel="manifest"/.test(shell.page({ service: 'no-such-site', canonical: 'https://x.example/' })), 'an unknown service adds none');
+ assert.match(shell.page({ name: 'OpenVibe.Watch', canonical: 'https://openvibe.watch/', navbar: { service: 'watch' } }), /shared\/manifest\/watch\.webmanifest\?v=/, 'the navbar names the service');
+
+ console.log('app-icons: ok');
+})().catch((e) => { console.error(e); process.exit(1); });