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)
Changes
@@@ -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><metaname="description"@@@ -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)@@@ -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): boolres.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.
@@ -4,6 +4,8 @@
@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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>
<title>OpenVibe.Games — browser games on the OpenVibe network</title>
<meta
<meta
name="description"
name="description"
@@ -171,6 +171,25 @@ describe('crawl artifacts (plan T11)', () => {
@@ -171,6 +171,25 @@ describe('crawl artifacts (plan T11)', () => {
for (const p of PRIVATE) expect(r.body).not.toContain(p)
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 () => {
it('serves /llms-full.txt: text/plain, the home page, and nothing robots keeps out', async () => {
const r = await send('/llms-full.txt')
const r = await send('/llms-full.txt')
expect(r.status).toBe(200)
expect(r.status).toBe(200)
@@ -82,6 +82,34 @@ interface SeoToolkit {
@@ -82,6 +82,34 @@ interface SeoToolkit {
jsonLdTag(node: unknown): string
jsonLdTag(node: unknown): string
}
}
const seo = createRequire(import.meta.url)('openvibe-shared/seo') as SeoToolkit
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 SITE = 'https://openvibe.games'
const PLAY = 'https://play.openvibe.games'
const PLAY = 'https://play.openvibe.games'
@@ -308,11 +336,14 @@ function send(res: ServerResponse, type: string, body: string): void {
@@ -308,11 +336,14 @@ function send(res: ServerResponse, type: string, body: string): void {
*/
*/
export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): boolean {
export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): boolean {
const url = (rq.url ?? '/').split('?')[0] ?? '/'
const url = (rq.url ?? '/').split('?')[0] ?? '/'
+ const icon = APP_ICON_RE.exec(url)
if (
if (
url !== '/robots.txt' &&
url !== '/robots.txt' &&
url !== '/sitemap.xml' &&
url !== '/sitemap.xml' &&
url !== '/llms.txt' &&
url !== '/llms.txt' &&
- url !== '/llms-full.txt'
+ url !== '/llms-full.txt' &&
+ url !== '/manifest.webmanifest' &&
+ !icon
) {
) {
return false
return false
}
}
@@ -321,6 +352,24 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
@@ -321,6 +352,24 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
res.end()
res.end()
return true
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') {
if (url === '/robots.txt') {
// The shared toolkit names search and AI crawlers by name, keeps every Disallow below and
// The shared toolkit names search and AI crawlers by name, keeps every Disallow below and
// always names the sitemap.
// always names the sitemap.