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)

OP OpenVibers committed 12 Oct 2026 · 4f2b4a7e36 · 41 files, +567 −21

A survey of the live sites found 25 of them without a web app manifest. With this release, every site whose pages go through `shell.page()` becomes an installable web app once it pins 4.1.0, with no per-site code.

- **Icons.** `icons/` holds four app icons (192 and 512, any and maskable) per app-icon tint variant. That is 8 sets, 32 palette PNGs and about 630 KB, because most sites share the network's tint. They are drawn by `scripts/build-icons.js` from `app-icon.svg({ site: variant, maskable })`.
- **Site metadata.** `icons/sites.json` gives each of the 35 services with a share card its name, tagline and variant, taken from Contracts 0.152.0's `site` blocks (media-hub is "OpenVibe.Video").
- **`openvibe-shared/app-icons`:** `has`, `url`, `site`, `isIcon`, `path`. The `icons` subpath was already the ring-icon module, so the new module has its own name.
- **`serve.js`** answers `/shared/icons/<variant>[-maskable]-<size>.png` and `/shared/manifest/<service>.webmanifest` (`application/manifest+json`), content-addressed like the other shared files. The manifest carries:
  - name, short name and description;
  - `start_url` and `scope` `/`, `display: standalone`, `id: /`;
  - the app-icon background and the navbar's theme colour;
  - the hashed icon URLs.

  An unknown service gets a 404.
- **`shell.page()`** adds `<link rel="manifest">` and `<link rel="apple-touch-icon">` for the page's service. It resolves the service the same way the share-card default does. `manifest: false` opts out, and a page whose `head` already has a manifest link keeps its own (Bot, Run, Media and Tips serve their own).

Built by a Command Code (DeepSeek V4 Pro) job and reviewed. During review I made two changes:
- One icon set per tint variant instead of one per site. The job's version shipped 140 PNGs (5.1 MB) in a package every repo installs, though most were identical.
- Site names and taglines rebuilt from current Contracts (the job had used 0.143, before "Video").

Checked in headless Chrome, on a `shell.page()` for wiki served through `serve.handler()`: `Page.getAppManifest` reports no errors and parses "OpenVibe.Wiki", and `Page.getInstallabilityErrors` reports none.

Tests: `app-icons.test.js` covers:
- every carded service has a set and its variant;
- exactly four icons per variant in use, valid PNGs of the right size;
- names follow Contracts, and only own entries count;
- serving with immutable and short-lived caching;
- the manifest's shape, and a 404 for an unknown service;
- one manifest link, plus the opt-outs.

The full suite passes 57 of 58 files; `floating-promises` is skipped without acorn, as usual. Version 4.1.0, minor: nothing existing changes.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex bc40d4a..ee96e3a 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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
ddiff --git a/README.md b/README.md
iindex 825a7d8..6531dfb 100644
---- a/README.md
++++ b/README.md
@@@ -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 head
Frame around them: `openvibe-shared/shell` (`shell.page(o)`) composes exactly that — see "The page
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`)
```text
ddiff --git a/app-icons.js b/app-icons.js
nnew file mode 100644
iindex 0000000..6c3521d
---- /dev/null
++++ b/app-icons.js
@@@ -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 };
ddiff --git a/icons/ai-192.png b/icons/ai-192.png
nnew file mode 100644
iindex 0000000..74f4207
BBinary files /dev/null and b/icons/ai-192.png differ
ddiff --git a/icons/ai-512.png b/icons/ai-512.png
nnew file mode 100644
iindex 0000000..7a4c3a8
BBinary files /dev/null and b/icons/ai-512.png differ
ddiff --git a/icons/ai-maskable-192.png b/icons/ai-maskable-192.png
nnew file mode 100644
iindex 0000000..34dd9f4
BBinary files /dev/null and b/icons/ai-maskable-192.png differ
ddiff --git a/icons/ai-maskable-512.png b/icons/ai-maskable-512.png
nnew file mode 100644
iindex 0000000..48d3399
BBinary files /dev/null and b/icons/ai-maskable-512.png differ
ddiff --git a/icons/community-192.png b/icons/community-192.png
nnew file mode 100644
iindex 0000000..2d71b8a
BBinary files /dev/null and b/icons/community-192.png differ
ddiff --git a/icons/community-512.png b/icons/community-512.png
nnew file mode 100644
iindex 0000000..472dfc5
BBinary files /dev/null and b/icons/community-512.png differ
ddiff --git a/icons/community-maskable-192.png b/icons/community-maskable-192.png
nnew file mode 100644
iindex 0000000..540a2d0
BBinary files /dev/null and b/icons/community-maskable-192.png differ
ddiff --git a/icons/community-maskable-512.png b/icons/community-maskable-512.png
nnew file mode 100644
iindex 0000000..6835d82
BBinary files /dev/null and b/icons/community-maskable-512.png differ
ddiff --git a/icons/games-192.png b/icons/games-192.png
nnew file mode 100644
iindex 0000000..6958c89
BBinary files /dev/null and b/icons/games-192.png differ
ddiff --git a/icons/games-512.png b/icons/games-512.png
nnew file mode 100644
iindex 0000000..d3c7d02
BBinary files /dev/null and b/icons/games-512.png differ
ddiff --git a/icons/games-maskable-192.png b/icons/games-maskable-192.png
nnew file mode 100644
iindex 0000000..be3f39e
BBinary files /dev/null and b/icons/games-maskable-192.png differ
ddiff --git a/icons/games-maskable-512.png b/icons/games-maskable-512.png
nnew file mode 100644
iindex 0000000..6e94db4
BBinary files /dev/null and b/icons/games-maskable-512.png differ
ddiff --git a/icons/live-192.png b/icons/live-192.png
nnew file mode 100644
iindex 0000000..7ec8de4
BBinary files /dev/null and b/icons/live-192.png differ
ddiff --git a/icons/live-512.png b/icons/live-512.png
nnew file mode 100644
iindex 0000000..56f9789
BBinary files /dev/null and b/icons/live-512.png differ
ddiff --git a/icons/live-maskable-192.png b/icons/live-maskable-192.png
nnew file mode 100644
iindex 0000000..af91270
BBinary files /dev/null and b/icons/live-maskable-192.png differ
ddiff --git a/icons/live-maskable-512.png b/icons/live-maskable-512.png
nnew file mode 100644
iindex 0000000..322c2bd
BBinary files /dev/null and b/icons/live-maskable-512.png differ
ddiff --git a/icons/media-192.png b/icons/media-192.png
nnew file mode 100644
iindex 0000000..676c954
BBinary files /dev/null and b/icons/media-192.png differ
ddiff --git a/icons/media-512.png b/icons/media-512.png
nnew file mode 100644
iindex 0000000..9864253
BBinary files /dev/null and b/icons/media-512.png differ
ddiff --git a/icons/media-maskable-192.png b/icons/media-maskable-192.png
nnew file mode 100644
iindex 0000000..247720f
BBinary files /dev/null and b/icons/media-maskable-192.png differ
ddiff --git a/icons/media-maskable-512.png b/icons/media-maskable-512.png
nnew file mode 100644
iindex 0000000..06cfcff
BBinary files /dev/null and b/icons/media-maskable-512.png differ
ddiff --git a/icons/network-192.png b/icons/network-192.png
nnew file mode 100644
iindex 0000000..d5457f0
BBinary files /dev/null and b/icons/network-192.png differ
ddiff --git a/icons/network-512.png b/icons/network-512.png
nnew file mode 100644
iindex 0000000..436ce37
BBinary files /dev/null and b/icons/network-512.png differ
ddiff --git a/icons/network-maskable-192.png b/icons/network-maskable-192.png
nnew file mode 100644
iindex 0000000..6fb261b
BBinary files /dev/null and b/icons/network-maskable-192.png differ
ddiff --git a/icons/network-maskable-512.png b/icons/network-maskable-512.png
nnew file mode 100644
iindex 0000000..4269980
BBinary files /dev/null and b/icons/network-maskable-512.png differ
ddiff --git a/icons/sites.json b/icons/sites.json
nnew file mode 100644
iindex 0000000..8ef671a
---- /dev/null
++++ b/icons/sites.json
@@@ -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"
++ }
++}
ddiff --git a/icons/space-192.png b/icons/space-192.png
nnew file mode 100644
iindex 0000000..92a0169
BBinary files /dev/null and b/icons/space-192.png differ
ddiff --git a/icons/space-512.png b/icons/space-512.png
nnew file mode 100644
iindex 0000000..440685b
BBinary files /dev/null and b/icons/space-512.png differ
ddiff --git a/icons/space-maskable-192.png b/icons/space-maskable-192.png
nnew file mode 100644
iindex 0000000..856dbf5
BBinary files /dev/null and b/icons/space-maskable-192.png differ
ddiff --git a/icons/space-maskable-512.png b/icons/space-maskable-512.png
nnew file mode 100644
iindex 0000000..fc7680e
BBinary files /dev/null and b/icons/space-maskable-512.png differ
ddiff --git a/icons/tools-192.png b/icons/tools-192.png
nnew file mode 100644
iindex 0000000..01c2425
BBinary files /dev/null and b/icons/tools-192.png differ
ddiff --git a/icons/tools-512.png b/icons/tools-512.png
nnew file mode 100644
iindex 0000000..a9b6ee4
BBinary files /dev/null and b/icons/tools-512.png differ
ddiff --git a/icons/tools-maskable-192.png b/icons/tools-maskable-192.png
nnew file mode 100644
iindex 0000000..07c2e20
BBinary files /dev/null and b/icons/tools-maskable-192.png differ
ddiff --git a/icons/tools-maskable-512.png b/icons/tools-maskable-512.png
nnew file mode 100644
iindex 0000000..4aa8004
BBinary files /dev/null and b/icons/tools-maskable-512.png differ
ddiff --git a/package.json b/package.json
iindex ad57d53..5669e88 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/scripts/build-icons.js b/scripts/build-icons.js
nnew file mode 100644
iindex 0000000..146ac18
---- /dev/null
++++ b/scripts/build-icons.js
@@@ -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); });
ddiff --git a/serve.js b/serve.js
iindex 12f859d..d9d9052 100644
---- a/serve.js
++++ b/serve.js
@@@ -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 };
ddiff --git a/shell.js b/shell.js
iindex 197b6ae..29aa969 100644
---- a/shell.js
++++ b/shell.js
@@@ -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)}>`,
ddiff --git a/test/app-icons.test.js b/test/app-icons.test.js
nnew file mode 100644
iindex 0000000..539dae3
---- /dev/null
++++ b/test/app-icons.test.js
@@@ -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); });