Example: keep the key, gate the buttons, ship the icons
Codex's review of the example: its run line minted a fresh key on every start while reusing the database, stranding every enrolled browser — the instructions now mint once and reuse. The buttons were live before their handlers existed, so an early click did nothing; they start disabled and are enabled once the worker is active and the key fetched, and stay disabled with a logged reason on a browser without push. The manifest had no icons and the page no Apple touch icon, so an author copying it would not get the installability recipe the docs describe; 192, 512 and 180 px PNGs are served and referenced, and the test checks the routes and the page.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
8 files changed,
+42
−12
example/index.html+7 −4example/main.go+6 −1example/main_test.go+15 −1example/static/app.js+10 −5example/static/icon-180.pngbinaryexample/static/icon-192.pngbinaryexample/static/icon-512.pngbinaryexample/static/manifest.webmanifest+4 −1
diff --git a/example/index.html b/example/index.html| index e8ae234..895ce32 100644 |
| --- a/example/index.html |
| +++ b/example/index.html |
| @@ -6,6 +6,7 @@ |
| <title>aviso example</title> |
| <link rel="manifest" href="/static/manifest.webmanifest"> |
| <meta name="theme-color" content="#5b6cff"> |
| + <link rel="apple-touch-icon" href="/static/icon-180.png"> |
| <style> |
| body { font: 16px/1.5 system-ui, sans-serif; margin: 2rem auto; max-width: 40rem; padding: 0 1rem; } |
| button { font: inherit; padding: .5rem 1rem; margin-right: .5rem; } |
| @@ -16,11 +17,13 @@ |
| <body> |
| <h1>aviso example</h1> |
| <p>Everyone here is signed in as <code>dev</code>. Enable push, then notify yourself.</p> |
| - <p id="coach">On a phone, add this app to your Home Screen and open it from there first — push is only delivered to an installed app.</p> |
| + <p id="coach">On an iPhone or iPad, add this app to your Home Screen (Share → Add to Home Screen) and open it from there first — iOS delivers push only to an installed app.</p> |
| <p> |
| - <button id="enable">Enable notifications</button> |
| - <button id="disable">Disable</button> |
| - <button id="notify">Notify me</button> |
| + <!-- Disabled until the worker is active and the key is fetched: |
| + a click before then would do nothing, silently. --> |
| + <button id="enable" disabled>Enable notifications</button> |
| + <button id="disable" disabled>Disable</button> |
| + <button id="notify" disabled>Notify me</button> |
| </p> |
| <pre id="log"></pre> |
| <script type="module" src="/static/app.js"></script> |
diff --git a/example/main.go b/example/main.go| index 8d72e2c..94aa50c 100644 |
| --- a/example/main.go |
| +++ b/example/main.go |
| @@ -4,7 +4,12 @@ |
| // "dev" — an example, not a pattern — so the enrol/send loop can be |
| // driven from one browser. |
| // |
| -// EXAMPLE_VAPID_PRIVATE_KEY="$(go run amadan.net/rastrillo/aviso/cmd/aviso-key)" go run . |
| +// Mint the key once and keep it — a subscription is bound to the key |
| +// it was made under, so a fresh key on every start would strand every |
| +// enrolled browser until it re-enrols (aviso.ErrKeyMismatch on send): |
| +// |
| +// go run amadan.net/rastrillo/aviso/cmd/aviso-key > .vapid-key # once |
| +// EXAMPLE_VAPID_PRIVATE_KEY="$(cat .vapid-key)" go run . # every start |
| // |
| // Push needs a secure context: http://localhost is one, so the default |
| // origin works locally without TLS. |
diff --git a/example/main_test.go b/example/main_test.go| index 95e8522..891e1c8 100644 |
| --- a/example/main_test.go |
| +++ b/example/main_test.go |
| @@ -55,7 +55,7 @@ func TestEverySeamIsWired(t *testing.T) { |
| "/static/aviso/aviso-sw.js": "AvisoSW", |
| "/sw.js": "importScripts(\"/static/aviso/aviso-sw.js\")", |
| "/aviso/public-key": "publicKey", |
| - "/static/manifest.webmanifest": "\"display\": \"standalone\"", |
| + "/static/manifest.webmanifest": "/static/icon-512.png", |
| } { |
| w := get(t, h, path) |
| if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), want) { |
| @@ -72,6 +72,20 @@ func TestEverySeamIsWired(t *testing.T) { |
| t.Errorf("%s Content-Type = %q", path, ct) |
| } |
| } |
| + // The installability recipe, modelled: manifest icons at 192 and |
| + // 512, an Apple touch icon at 180 linked from the page, and |
| + // buttons that start disabled until the page can act on a click. |
| + for _, path := range []string{"/static/icon-192.png", "/static/icon-512.png", "/static/icon-180.png"} { |
| + if w := get(t, h, path); w.Code != http.StatusOK || !strings.HasPrefix(w.Header().Get("Content-Type"), "image/png") { |
| + t.Errorf("%s: %d %q", path, w.Code, w.Header().Get("Content-Type")) |
| + } |
| + } |
| + page := get(t, h, "/").Body.String() |
| + for _, want := range []string{`rel="apple-touch-icon" href="/static/icon-180.png"`, `rel="manifest"`, `id="enable" disabled`, `id="notify" disabled`} { |
| + if !strings.Contains(page, want) { |
| + t.Errorf("index.html lacks %q", want) |
| + } |
| + } |
| k, _ := ecdh.P256().GenerateKey(rand.Reader) |
| auth := make([]byte, 16) |
diff --git a/example/static/app.js b/example/static/app.js| index bb02443..3133e22 100644 |
| --- a/example/static/app.js |
| +++ b/example/static/app.js |
| @@ -1,7 +1,11 @@ |
| -// The page half, as SKILL.md shows it. |
| +// The page half, as SKILL.md shows it. The buttons start disabled and |
| +// are enabled only once they can do something: after the worker is |
| +// active and the key is fetched. On a browser without push they stay |
| +// disabled, with the reason logged, rather than sitting there inert. |
| import { enable, reconcile, disable, capabilities, status } from "/static/aviso/push.mjs"; |
| const log = (line) => { document.getElementById("log").textContent += line + "\n"; }; |
| +const button = (id) => document.getElementById(id); |
| const post = (path) => (body) => fetch(path, { |
| method: "POST", credentials: "same-origin", |
| headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), |
| @@ -15,7 +19,7 @@ if (!caps.standalone && /iPhone|iPad/.test(navigator.userAgent)) { |
| document.getElementById("coach").style.display = "block"; |
| } |
| if (!caps.serviceWorker || !caps.push) { |
| - log("this browser cannot do push"); |
| + log("this browser cannot do push; the buttons stay disabled"); |
| } else { |
| await navigator.serviceWorker.register("/sw.js"); |
| // Active, not merely registered: subscribe() on an installing |
| @@ -29,18 +33,19 @@ if (!caps.serviceWorker || !caps.push) { |
| log("subscribed: " + !!repaired); |
| // From the click, synchronously: enable prompts before its first await. |
| - document.getElementById("enable").onclick = () => { |
| + button("enable").onclick = () => { |
| enable({ registration, publicKey, save }) |
| .then((sub) => log(sub ? "enabled: " + sub.endpoint.slice(0, 40) + "…" : "permission denied")) |
| .catch((e) => log("enable: " + e.message)); |
| }; |
| - document.getElementById("disable").onclick = async () => { |
| + button("disable").onclick = async () => { |
| try { await disable({ registration, remove }); log("disabled"); } catch (e) { log("disable: " + e.message); } |
| }; |
| - document.getElementById("notify").onclick = async () => { |
| + button("notify").onclick = async () => { |
| const r = await fetch("/notify", { method: "POST", credentials: "same-origin" }); |
| log("notify: " + r.status + " " + await r.text()); |
| }; |
| + for (const id of ["enable", "disable", "notify"]) button(id).disabled = false; |
| const s = await status(registration); |
| log("permission: " + s.permission); |
| } |
diff --git a/example/static/icon-180.png b/example/static/icon-180.png| new file mode 100644 |
| index 0000000..c324633 |
| Binary files /dev/null and b/example/static/icon-180.png differ |
diff --git a/example/static/icon-192.png b/example/static/icon-192.png| new file mode 100644 |
| index 0000000..757149e |
| Binary files /dev/null and b/example/static/icon-192.png differ |
diff --git a/example/static/icon-512.png b/example/static/icon-512.png| new file mode 100644 |
| index 0000000..3237518 |
| Binary files /dev/null and b/example/static/icon-512.png differ |
diff --git a/example/static/manifest.webmanifest b/example/static/manifest.webmanifest| index bdbb5de..8a7cd8c 100644 |
| --- a/example/static/manifest.webmanifest |
| +++ b/example/static/manifest.webmanifest |
| @@ -7,5 +7,8 @@ |
| "display": "standalone", |
| "theme_color": "#5b6cff", |
| "background_color": "#f6f7fb", |
| - "icons": [] |
| + "icons": [ |
| + { "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" }, |
| + { "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" } |
| + ] |
| } |