openvibers/openvibe.games Commits

OpenVibe.Games is installable: /manifest.webmanifest and its icons (the shared Games set from openvibe-shared/app-icons), linked from the client with an apple-touch-icon (#100)

OP OpenVibers committed 27 days ago · 8294b9dbd6 · 3 files, +71 −1

Changes

ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex 6c87ea0..753c107 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
++ <link rel="manifest" href="/manifest.webmanifest" />
++ <link rel="apple-touch-icon" href="/app-icons/games-192.png" />
<title>OpenVibe.Games — browser games on the OpenVibe network</title>
<meta
name="description"
ddiff --git a/apps/server/src/net/discovery.test.ts b/apps/server/src/net/discovery.test.ts
iindex 7dcdb00..a29c421 100644
---- a/apps/server/src/net/discovery.test.ts
++++ b/apps/server/src/net/discovery.test.ts
@@@ -171,6 +171,25 @@ describe('crawl artifacts (plan T11)', () => {
for (const p of PRIVATE) expect(r.body).not.toContain(p)
})
++ it('serves the installable app: /manifest.webmanifest and its icons (the shared Games set)', async () => {
++ const m = await send('/manifest.webmanifest')
++ expect(m.status).toBe(200)
++ expect(m.headers['content-type']).toBe('application/manifest+json; charset=utf-8')
++ const manifest = JSON.parse(m.body) as { name: string; start_url: string; icons: { src: string; purpose: string }[] }
++ expect(manifest.name).toBe('OpenVibe.Games')
++ expect(manifest.start_url).toBe('/')
++ expect(manifest.icons.map((i) => i.src)).toEqual([
++ '/app-icons/games-192.png', '/app-icons/games-maskable-192.png', '/app-icons/games-512.png', '/app-icons/games-maskable-512.png',
++ ])
++ for (const i of manifest.icons) {
++ const r = await send(i.src)
++ expect(r.status).toBe(200)
++ expect(r.headers['content-type']).toBe('image/png')
++ }
++ // Nothing else under /app-icons reaches the file system.
++ expect((await send('/app-icons/..%2F..%2Fpackage.json')).status).not.toBe(200)
++ })
++
it('serves /llms-full.txt: text/plain, the home page, and nothing robots keeps out', async () => {
const r = await send('/llms-full.txt')
expect(r.status).toBe(200)
ddiff --git a/apps/server/src/net/discovery.ts b/apps/server/src/net/discovery.ts
iindex eac69be..4b90dd0 100644
---- a/apps/server/src/net/discovery.ts
++++ b/apps/server/src/net/discovery.ts
@@@ -82,6 +82,34 @@ interface SeoToolkit {
jsonLdTag(node: unknown): string
}
const seo = createRequire(import.meta.url)('openvibe-shared/seo') as SeoToolkit
++// The installable app: the shared OpenVibe icon set for Games (openvibe-shared/app-icons, pre-rendered PNGs).
++const appIcons = createRequire(import.meta.url)('openvibe-shared/app-icons') as {
++ path(name: string): string
++ site(service: string): { name: string; description?: string } | null
++}
++const APP_ICON_RE = /^\/app-icons\/(games-(?:maskable-)?(?:192|512)\.png)$/
++function manifestBody(): string {
++ const icons = [192, 512].flatMap((size) =>
++ [false, true].map((maskable) => ({
++ src: `/app-icons/games-${maskable ? 'maskable-' : ''}${size}.png`,
++ sizes: `${size}x${size}`,
++ type: 'image/png',
++ purpose: maskable ? 'maskable' : 'any',
++ })),
++ )
++ return JSON.stringify({
++ name: 'OpenVibe.Games',
++ short_name: 'Games',
++ description: appIcons.site('games')?.description ?? 'Browser games',
++ id: '/',
++ start_url: '/',
++ scope: '/',
++ display: 'standalone',
++ background_color: '#0a0f1c',
++ theme_color: '#0d131d',
++ icons,
++ })
++}
const SITE = 'https://openvibe.games'
const PLAY = 'https://play.openvibe.games'
@@@ -308,11 +336,14 @@ function send(res: ServerResponse, type: string, body: string): void {
*/
export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): boolean {
const url = (rq.url ?? '/').split('?')[0] ?? '/'
++ const icon = APP_ICON_RE.exec(url)
if (
url !== '/robots.txt' &&
url !== '/sitemap.xml' &&
url !== '/llms.txt' &&
-- url !== '/llms-full.txt'
++ url !== '/llms-full.txt' &&
++ url !== '/manifest.webmanifest' &&
++ !icon
) {
return false
}
@@@ -321,6 +352,24 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
res.end()
return true
}
++ if (url === '/manifest.webmanifest') {
++ send(res, 'application/manifest+json', manifestBody())
++ return true
++ }
++ if (icon) {
++ // Only the names the regex allows reach the file system (openvibe-shared/app-icons resolves them).
++ let png: Buffer
++ try {
++ png = readFileSync(appIcons.path(`icons/${icon[1]}`))
++ } catch {
++ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
++ res.end('Not found')
++ return true
++ }
++ res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'public, max-age=86400' })
++ res.end(png)
++ return true
++ }
if (url === '/robots.txt') {
// The shared toolkit names search and AI crawlers by name, keeps every Disallow below and
// always names the sitemap.