commit 28b2e08872df95d7a1c136e1bdb5bcc3bf84f684 equwal <13551856+equwal@users.noreply.github.com> 2026-09-22 18:08:13 -0700 ideamine 0.11.0: the dashboard as an app on a phone The page now carries a manifest, icons, and a service worker, so a phone can put it on the home screen and open it without the address bar. On Android over HTTPS, Chrome installs it, and the app takes a share: a text from another app opens the composer with that text. ?text=... and ?new=1 do the same for a shortcut. The service worker keeps only the page and its icons. The ideas, the prompts, and the memories always come from the server, because an old copy of them would say the wrong thing. dashboard/icons.mjs draws the icons with plain arithmetic and writes the PNG files, so the repository needs no graphics program. `ideamine serve` serves the new files, and `ideamine publish` uploads them.
.claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- README.md | 11 +++ dashboard/apple-touch-icon.png | Bin 0 -> 5180 bytes dashboard/icon-192.png | Bin 0 -> 5556 bytes dashboard/icon-512.png | Bin 0 -> 16886 bytes dashboard/icon-maskable-512.png | Bin 0 -> 13453 bytes dashboard/icons.mjs | 161 ++++++++++++++++++++++++++++++++++++++++ dashboard/index.html | 47 +++++++++++- dashboard/manifest.webmanifest | 25 +++++++ dashboard/sw.js | 37 +++++++++ package.json | 2 +- src/publish.js | 3 + src/serve.js | 21 ++++++ tests/icons.test.js | 45 +++++++++++ tests/publish.test.js | 25 ++++++- tests/serve.test.js | 23 ++++++ 17 files changed, 395 insertions(+), 9 deletions(-)
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a668694..7cf3f19 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ { "name": "ideamine", "description": "An idea inbox for Claude Code. /idea saves an idea without a model call. Claude triages the ideas and picks the cheapest model that can build each one.", - "version": "0.10.0", + "version": "0.11.0", "author": { "name": "equwal" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6595ecd..d3591e4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ideamine", - "version": "0.10.0", + "version": "0.11.0", "description": "An idea inbox for Claude Code. /idea saves an idea without a model call, so it works at any time, even at your usage limit. Claude triages the ideas and picks the cheapest model that can build each one.", "author": { "name": "equwal", diff --git a/README.md b/README.md index 314f655..9d028c4 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,17 @@ server { `serve_hosts` names the Host that nginx sends. Without it, the server refuses the request, so a DNS rebinding page cannot reach the archive. The server does not ask who you are: the network decides who can reach it. Put it only on a private network like WireGuard. +### On your phone + +The dashboard is also an app for a phone. Open it and put it on the home screen: it gets the ideamine icon and opens without the address bar. + +- **iPhone or iPad**: open the page in Safari, then "Share" and "Add to Home Screen". This works over plain HTTP, so it needs nothing else. +- **Android**: Chrome offers "Install app" when the page comes over HTTPS. The installed app also takes a share: send a text from another app to ideamine, and the page opens with that text in the composer. +- `?text=...` and `?new=1` open the composer too, so a shortcut or another app can save an idea in one step. +- The service worker keeps the page and its icons, so the app opens when the tunnel to the server is down. The ideas, the prompts, and the memories always come from the server: an old copy of them would say the wrong thing. + +The icons are in the repository. `node dashboard/icons.mjs` draws them again. + ### Prompts, Memory, and Usage The dashboard can also show what your Claudes did: diff --git a/dashboard/apple-touch-icon.png b/dashboard/apple-touch-icon.png new file mode 100644 index 0000000..37dba80 Binary files /dev/null and b/dashboard/apple-touch-icon.png differ diff --git a/dashboard/icon-192.png b/dashboard/icon-192.png new file mode 100644 index 0000000..c204d73 Binary files /dev/null and b/dashboard/icon-192.png differ diff --git a/dashboard/icon-512.png b/dashboard/icon-512.png new file mode 100644 index 0000000..f811fca Binary files /dev/null and b/dashboard/icon-512.png differ diff --git a/dashboard/icon-maskable-512.png b/dashboard/icon-maskable-512.png new file mode 100644 index 0000000..3078cd1 Binary files /dev/null and b/dashboard/icon-maskable-512.png differ diff --git a/dashboard/icons.mjs b/dashboard/icons.mjs new file mode 100644 index 0000000..ccd3464 --- /dev/null +++ b/dashboard/icons.mjs @@ -0,0 +1,161 @@ +// The icons of the dashboard for a phone: a lamp on the dark of the board. `node dashboard/icons.mjs` +// writes them again. They are in the repository, so the server needs no drawing program. +// +// The drawing is plain arithmetic on a pixel buffer, at four times the size, and then averaged +// down. That gives smooth edges without a graphics library. + +import { deflateSync } from 'node:zlib'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DIR = path.dirname(fileURLToPath(import.meta.url)); +const BG = [11, 11, 11]; // --bg of the dark theme +const LAMP = [57, 135, 229]; // --accent +const BASE = [195, 194, 183]; // --text-dim +const SUPER = 4; + +const mix = (under, over, alpha) => under.map((c, i) => Math.round(c * (1 - alpha) + over[i] * alpha)); + +/** A rounded square that holds the whole icon, or the full square for a maskable icon. */ +function inTile(x, y, size, radius) { + const near = (v, edge) => Math.min(v, edge - 1 - v); + const dx = near(x, size); + const dy = near(y, size); + if (dx >= radius || dy >= radius) return true; + return (radius - dx) ** 2 + (radius - dy) ** 2 <= radius ** 2; +} + +const inCircle = (x, y, cx, cy, r) => (x - cx) ** 2 + (y - cy) ** 2 <= r * r; + +/** Distance from the point to the line piece a→b, for the filament of the lamp. */ +function distToSegment(x, y, ax, ay, bx, by) { + const vx = bx - ax; + const vy = by - ay; + const len = vx * vx + vy * vy; + const t = len ? Math.max(0, Math.min(1, ((x - ax) * vx + (y - ay) * vy) / len)) : 0; + return Math.hypot(x - (ax + t * vx), y - (ay + t * vy)); +} + +/** One pixel of the icon: the tile, the glow, the lamp, its filament, and the screw base. */ +function pixel(x, y, size, maskable) { + if (!inTile(x, y, size, maskable ? 0 : size * 0.1875)) return null; // outside the rounded square + const cx = size / 2; + const cy = size * 0.44; + const r = size * (maskable ? 0.17 : 0.21); + let color = BG; + const glow = Math.hypot(x - cx, y - cy) / (r * 2.1); + if (glow < 1) color = mix(color, LAMP, 0.45 * (1 - glow) ** 2); + const base = [ + [cy + r * 1.02, r * 0.24], + [cy + r * 1.42, r * 0.24], + [cy + r * 1.82, r * 0.24], + ]; + for (const [top, height] of base) { + if (y >= top && y <= top + height && Math.abs(x - cx) <= r * 0.45) color = BASE; + } + if (inCircle(x, y, cx, cy, r)) { + color = LAMP; + const wire = r * 0.09; + const points = [ + [cx - r * 0.42, cy + r * 0.15], + [cx - r * 0.14, cy - r * 0.35], + [cx + r * 0.14, cy + r * 0.15], + [cx + r * 0.42, cy - r * 0.35], + ]; + for (let i = 0; i < points.length - 1; i++) { + if (distToSegment(x, y, ...points[i], ...points[i + 1]) <= wire) color = BG; + } + } + return color; +} + +/** The pixels of one icon, averaged down from four times the size. */ +function draw(size, maskable) { + const big = size * SUPER; + const out = Buffer.alloc(size * size * 4); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + let r = 0; + let g = 0; + let b = 0; + let a = 0; + for (let sy = 0; sy < SUPER; sy++) { + for (let sx = 0; sx < SUPER; sx++) { + const color = pixel(x * SUPER + sx + 0.5, y * SUPER + sy + 0.5, big, maskable); + if (!color) continue; + r += color[0]; + g += color[1]; + b += color[2]; + a += 255; + } + } + const n = SUPER * SUPER; + const i = (y * size + x) * 4; + const solid = a / 255 || 1; + out[i] = Math.round(r / solid); + out[i + 1] = Math.round(g / solid); + out[i + 2] = Math.round(b / solid); + out[i + 3] = Math.round(a / n); + } + } + return out; +} + +const CRC = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(buf) { + let c = 0xffffffff; + for (const byte of buf) c = CRC[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, 'ascii'); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); + return Buffer.concat([head, data, crc]); +} + +/** An RGBA pixel buffer as a PNG file. */ +export function png(pixels, size) { + const head = Buffer.alloc(13); + head.writeUInt32BE(size, 0); + head.writeUInt32BE(size, 4); + head[8] = 8; // bits for each channel + head[9] = 6; // RGBA + const rows = Buffer.alloc(size * (size * 4 + 1)); + for (let y = 0; y < size; y++) { + rows[y * (size * 4 + 1)] = 0; // filter: none + pixels.copy(rows, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); + } + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', head), + chunk('IDAT', deflateSync(rows, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +export const icon = (size, { maskable = false } = {}) => png(draw(size, maskable), size); + +export const ICONS = [ + ['icon-192.png', 192, {}], + ['icon-512.png', 512, {}], + ['icon-maskable-512.png', 512, { maskable: true }], + ['apple-touch-icon.png', 180, {}], +]; + +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) { + for (const [name, size, opts] of ICONS) { + const file = path.join(DIR, name); + fs.writeFileSync(file, icon(size, opts)); + console.log(`${name} ${size}x${size} ${fs.statSync(file).size} bytes`); + } +} diff --git a/dashboard/index.html b/dashboard/index.html index 1599de4..ad3adc2 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -2,9 +2,19 @@ <html lang="en"> <head> <meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1"> +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:"> <meta name="color-scheme" content="light dark"> +<meta name="theme-color" content="#f9f9f7" media="(prefers-color-scheme: light)"> +<meta name="theme-color" content="#0b0b0b" media="(prefers-color-scheme: dark)"> +<!-- On a phone: an icon on the home screen, and a window without the address bar. --> +<link rel="manifest" href="manifest.webmanifest"> +<link rel="apple-touch-icon" href="apple-touch-icon.png"> +<link rel="icon" href="icon-192.png" type="image/png"> +<meta name="apple-mobile-web-app-capable" content="yes"> +<meta name="apple-mobile-web-app-title" content="ideamine"> +<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> +<meta name="mobile-web-app-capable" content="yes"> <title>ideamine</title> <style> /* Theme tokens. Dark mode overrides the same names. */ @@ -77,7 +87,9 @@ button { font: inherit; color: inherit; } code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } /* header */ -.app-header { position: sticky; top: 0; z-index: 5; background: var(--surface); border-bottom: 1px solid var(--border); } +.app-header { position: sticky; top: 0; z-index: 5; background: var(--surface); border-bottom: 1px solid var(--border); padding-top: env(safe-area-inset-top); } +/* On a phone in standalone mode, nothing hides under the notch or the home bar. */ +.header-row { padding-left: max(12px, env(safe-area-inset-left)); padding-right: max(12px, env(safe-area-inset-right)); } .header-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 6px 12px; } .header-top { justify-content: space-between; padding-top: 10px; } .brand { font-size: 18px; font-weight: 700; } @@ -93,7 +105,7 @@ code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp .search-input { flex: 1 1 160px; min-width: 120px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); } /* layout */ -main { padding: 12px; } +main { padding: 12px; padding-left: max(12px, env(safe-area-inset-left)); padding-right: max(12px, env(safe-area-inset-right)); padding-bottom: max(12px, env(safe-area-inset-bottom)); } .empty-note { color: var(--text-muted); padding: 24px 4px; } .load-error { padding: 24px; color: var(--lane-dropped); } @@ -1959,13 +1971,42 @@ async function loadData() { } } +/* ---- the app on a phone ---- */ + +/** + * Text that the phone shared with the app, or the "New idea" shortcut: the composer opens with the + * text in it. The address goes back to plain, so a reload does not bring the same text again. + */ +function applyShare() { + const query = new URLSearchParams(location.search); + const shared = ['title', 'text', 'url'].map((key) => query.get(key)).filter(Boolean).join('\n'); + if (!shared && !query.has('new')) return; + history.replaceState(null, '', location.pathname + location.hash); + if (!isLive()) { + showToast('This copy only shows the ideas. Open the page of your ideamine server to save one.', 'error'); + return; + } + $composer.hidden = false; + $composerText.value = shared; + $composerText.focus(); + if (shared) $composerText.setSelectionRange(shared.length, shared.length); +} + +// A browser starts a service worker only on https or on localhost. Without it the app still works. +function registerWorker() { + const secure = location.protocol === 'https:' || ['localhost', '127.0.0.1'].includes(location.hostname); + if (secure && 'serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {}); +} + async function main() { bindStaticEvents(); + registerWorker(); try { await loadData(); window.addEventListener('hashchange', onHashChange); applyHash(); render(); + applyShare(); } catch (err) { renderLoadError(err); } diff --git a/dashboard/manifest.webmanifest b/dashboard/manifest.webmanifest new file mode 100644 index 0000000..48957a7 --- /dev/null +++ b/dashboard/manifest.webmanifest @@ -0,0 +1,25 @@ +{ + "name": "ideamine", + "short_name": "ideamine", + "description": "Your ideas, your prompts, and the memory of your Claudes, on every machine.", + "start_url": ".", + "scope": ".", + "display": "standalone", + "background_color": "#0b0b0b", + "theme_color": "#0b0b0b", + "icons": [ + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ], + "share_target": { + "action": ".", + "method": "GET", + "params": { "title": "title", "text": "text", "url": "url" } + }, + "shortcuts": [ + { "name": "New idea", "short_name": "New idea", "url": "./?new=1", "icons": [{ "src": "icon-192.png", "sizes": "192x192" }] }, + { "name": "Prompts", "short_name": "Prompts", "url": "./#prompts" }, + { "name": "Memory", "short_name": "Memory", "url": "./#memory" } + ] +} diff --git a/dashboard/sw.js b/dashboard/sw.js new file mode 100644 index 0000000..c68f2a9 --- /dev/null +++ b/dashboard/sw.js @@ -0,0 +1,37 @@ +// The service worker of the dashboard on a phone. It keeps the page and its icons, so that the app +// opens when the tunnel to the server is down. The archive, the prompts, and the memories always +// come from the server: a copy of them would show old ideas as if they were current. +// +// A browser starts a service worker only on https or on localhost, so the page registers it only +// there. Without it the page still works; it only needs the network each time. + +const CACHE = 'ideamine-shell-v1'; +const SHELL = ['./', './manifest.webmanifest', './icon-192.png', './icon-512.png', './icon-maskable-512.png', './apple-touch-icon.png']; + +self.addEventListener('install', (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting())); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((names) => Promise.all(names.filter((name) => name !== CACHE).map((name) => caches.delete(name)))) + .then(() => self.clients.claim()), + ); +}); + +const live = (url) => url.pathname.endsWith('/data.json') || url.pathname.includes('/api/') || url.pathname.includes('/v1/'); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (event.request.method !== 'GET' || url.origin !== self.location.origin || live(url)) return; // the server answers + event.respondWith( + fetch(event.request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE).then((cache) => cache.put(event.request, copy)); + return response; + }) + .catch(() => caches.match(event.request).then((hit) => hit || caches.match('./'))), + ); +}); diff --git a/package.json b/package.json index 3245048..90392c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ideamine", - "version": "0.10.0", + "version": "0.11.0", "description": "An idea inbox for Claude Code. /idea saves an idea without calling the model; Claude later triages each idea and picks the cheapest model that can build it.", "type": "module", "bin": { diff --git a/src/publish.js b/src/publish.js index e220d55..34e0168 100644 --- a/src/publish.js +++ b/src/publish.js @@ -231,9 +231,12 @@ export async function publish({ url = config.get('publish_url'), dir = null } = if (!url && !dir) throw new Error('no dashboard server is set. Run `ideamine publish <url>` once, or pass --dir <folder>'); const mtime = archiveTime(); const { data, note } = await build(load()); + const { ASSETS, assetPath } = await import('./serve.js'); const files = [ ['data.json', JSON.stringify(data), 'application/json'], ['index.html', fs.readFileSync(PAGE), 'text/html; charset=utf-8'], + // The manifest, the icons, and the service worker: the page is an app on a phone with them. + ...Object.entries(ASSETS).map(([name, type]) => [name, fs.readFileSync(assetPath(name)), type]), ]; if (dir) { fs.mkdirSync(dir, { recursive: true }); diff --git a/src/serve.js b/src/serve.js index 9044582..5e10ad1 100644 --- a/src/serve.js +++ b/src/serve.js @@ -97,6 +97,26 @@ function send(res, status, json, headers = {}) { res.end(JSON.stringify(json)); } +// The files that make the page an app on a phone: the manifest, the icons, and the service worker. +// They sit next to the page, and `ideamine publish` uploads them with it. +export const ASSETS = { + 'manifest.webmanifest': 'application/manifest+json; charset=utf-8', + 'sw.js': 'text/javascript; charset=utf-8', + 'icon-192.png': 'image/png', + 'icon-512.png': 'image/png', + 'icon-maskable-512.png': 'image/png', + 'apple-touch-icon.png': 'image/png', +}; + +export const assetPath = (name) => fileURLToPath(new URL(`../dashboard/${name}`, import.meta.url)); + +function sendAsset(res, name) { + // A service worker must come from the folder that it controls, so it is never cached long. + const cache = name === 'sw.js' ? 'no-store' : 'public, max-age=86400'; + res.writeHead(200, { ...HEADERS, 'cache-control': cache, 'content-type': ASSETS[name] }); + res.end(fs.readFileSync(assetPath(name))); +} + function sendPage(res) { res.writeHead(200, { ...HEADERS, @@ -499,6 +519,7 @@ async function handle(req, res, ctx) { const { pathname, search, searchParams } = new URL(req.url, 'http://127.0.0.1'); const route = `${req.method === 'HEAD' ? 'GET' : req.method} ${pathname}`; if (route === 'GET /' || route === 'GET /index.html') return sendPage(res); + if (req.method === 'GET' && Object.hasOwn(ASSETS, pathname.slice(1))) return sendAsset(res, pathname.slice(1)); if (route === 'GET /data.json') return send(res, 200, await liveData()); // A ping gets a new connection each time, so it never reaches a server that stops on an old one. if (route === 'GET /api/ping') return send(res, 200, { ok: true, app: 'ideamine' }, { connection: 'close' }); diff --git a/tests/icons.test.js b/tests/icons.test.js new file mode 100644 index 0000000..aee1970 --- /dev/null +++ b/tests/icons.test.js @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { inflateSync } from 'node:zlib'; +import { icon, ICONS } from '../dashboard/icons.mjs'; + +/** The pixels of a PNG that icons.mjs wrote: signature, size, and RGBA rows without a filter. */ +function decode(png) { + assert.deepEqual([...png.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 'PNG signature'); + assert.equal(png.subarray(12, 16).toString('ascii'), 'IHDR'); + const width = png.readUInt32BE(16); + const height = png.readUInt32BE(20); + assert.equal(png.subarray(37, 41).toString('ascii'), 'IDAT'); + const rows = inflateSync(png.subarray(41, 41 + png.readUInt32BE(33))); + const at = (x, y) => { + const start = y * (width * 4 + 1); + assert.equal(rows[start], 0, 'each row says "no filter"'); + return [...rows.subarray(start + 1 + x * 4, start + 5 + x * 4)]; + }; + return { width, height, at }; +} + +test('each icon in the repository is the PNG that the drawing makes now', () => { + for (const [name, wanted, opts] of ICONS) { + const made = icon(wanted, opts); + const { width, height } = decode(made); + assert.deepEqual([width, height], [wanted, wanted], name); + const file = fs.readFileSync(fileURLToPath(new URL(`../dashboard/${name}`, import.meta.url))); + assert.ok(made.equals(file), `${name} differs from the drawing. Run: node dashboard/icons.mjs`); + } +}); + +test('the icon is a blue lamp on a dark tile with round corners, and the maskable icon is a full square', () => { + const plain = decode(icon(192)); + assert.deepEqual(plain.at(0, 0), [0, 0, 0, 0], 'the corner is outside the rounded tile'); + assert.deepEqual(plain.at(96, 4), [11, 11, 11, 255], 'the top middle is the dark tile'); + const middle = Math.round(192 * 0.44); + const inside = Math.round(96 + 0.75 * 0.21 * 192); // in the glass, beside the filament + assert.deepEqual(plain.at(inside, middle), [57, 135, 229, 255], 'the glass of the lamp is the accent colour'); + assert.deepEqual(plain.at(96, middle), [11, 11, 11, 255], 'the filament crosses the middle and is dark'); + + const maskable = decode(icon(512, { maskable: true })); + assert.equal(maskable.at(0, 0)[3], 255, 'a maskable icon fills its square, so a phone can cut any shape'); +}); diff --git a/tests/publish.test.js b/tests/publish.test.js index 40cd97b..28cc3b8 100644 --- a/tests/publish.test.js +++ b/tests/publish.test.js @@ -85,7 +85,16 @@ test('publish uploads data.json and index.html with PUT, and kick() starts again assert.equal(kick(), true); // never published const out = await publish.publish(); assert.deepEqual([out.where, out.ideas], [`${server.url}/dash`, 1]); - assert.deepEqual([...server.files.keys()], ['/dash/data.json', '/dash/index.html']); + assert.deepEqual([...server.files.keys()].sort(), [ + '/dash/apple-touch-icon.png', + '/dash/data.json', + '/dash/icon-192.png', + '/dash/icon-512.png', + '/dash/icon-maskable-512.png', + '/dash/index.html', + '/dash/manifest.webmanifest', + '/dash/sw.js', + ]); assert.equal(JSON.parse(server.files.get('/dash/data.json')).ideas[0].title, 'one idea'); assert.match(server.files.get('/dash/index.html'), /<html/i); assert.equal(kick(), false); // nothing changed @@ -121,12 +130,22 @@ test('a background publish that cannot start is recorded, and the caller goes on assert.equal(publish.kick({ start: () => assert.fail('must wait after the error') }), false); }); -test('publish --dir writes both files to a folder', async () => { +test('publish --dir writes the page, the data, and what a phone needs', async () => { store.addIdeas(['one idea']); const dir = path.join(store.home(), 'out'); const out = await publish.publish({ dir }); assert.equal(out.where, dir); - assert.deepEqual(fs.readdirSync(dir).sort(), ['data.json', 'index.html']); + assert.deepEqual(fs.readdirSync(dir).sort(), [ + 'apple-touch-icon.png', + 'data.json', + 'icon-192.png', + 'icon-512.png', + 'icon-maskable-512.png', + 'index.html', + 'manifest.webmanifest', + 'sw.js', + ]); + assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'manifest.webmanifest'), 'utf8')).name, 'ideamine'); }); test('config: the environment wins over the file, and an empty value restores the default', () => { diff --git a/tests/serve.test.js b/tests/serve.test.js index ec74075..1d344d6 100644 --- a/tests/serve.test.js +++ b/tests/serve.test.js @@ -225,6 +225,29 @@ test('search by meaning on the page goes through the server to the embedding ser assert.deepEqual(embedServer.inputs, ['search_query: subtitles']); }); +test('the page comes with what a phone needs: a manifest, icons, and a service worker', async () => { + const get = (route) => fetch(new URL(route, url)); + const manifest = await get('manifest.webmanifest'); + assert.equal(manifest.headers.get('content-type'), 'application/manifest+json; charset=utf-8'); + const app = await manifest.json(); + assert.equal(app.display, 'standalone'); + assert.deepEqual(app.icons.map((i) => i.sizes), ['192x192', '512x512', '512x512']); + assert.equal(app.share_target.params.text, 'text'); // a phone can share a text into the composer + const worker = await get('sw.js'); + assert.match(worker.headers.get('content-type'), /javascript/); + assert.equal(worker.headers.get('cache-control'), 'no-store'); + assert.match(await worker.text(), /addEventListener\('fetch'/); + for (const [name, type] of [['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['apple-touch-icon.png', 'image/png']]) { + const res = await get(name); + assert.equal(res.headers.get('content-type'), type); + const bytes = Buffer.from(await res.arrayBuffer()); + assert.deepEqual([...bytes.subarray(1, 4)], [0x50, 0x4e, 0x47], `${name} is a PNG`); + } + const page = await (await get('index.html')).text(); + assert.match(page, /<link rel="manifest" href="manifest\.webmanifest">/); + assert.match(page, /apple-mobile-web-app-capable/); +}); + test('the server role keeps the prompts of every machine, each once, and gives them by days', async () => { const recent = new Date(Date.now() - 3600000).toISOString(); const prompts = [