Repository navigation
feat(kix): install kix.quest as a phone app with push notifications #165
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
89638dc
chore(quest): claim quest/m0/kix/mobile.md
kixelated 8a1a990
feat(kix): install as a phone app with decision push notifications
kixelated 6c75c92
Merge remote-tracking branch 'origin/main' into merge-165
kixelated 6f17641
fix(push): keep notification state honest after merging main
kixelated e390467
Merge remote-tracking branch 'origin/main' into merge-165
kixelated dc9e51c
fix(push): never hand a device to another user, and push only to curr…
kixelated 7c64c41
Merge remote-tracking branch 'origin/main' into merge-165
kixelated 8132d53
fix(push): keep opt-in through subscription and key rotation
kixelated 17b3bcb
Merge remote-tracking branch 'origin/main' into merge-165
kixelated 7a68873
fix(push): drop the browser's subscription when signing out
kixelated dadfe66
fix(push): count the shown decision as seen, and clear the badge on s…
kixelated 4df9be7
Merge remote-tracking branch 'origin/main' into merge-165
kixelated 4ad2bf6
Merge origin/main into quest/m0/kix/mobile
kixelated File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| -- Web Push subscriptions, one per browser or installed app. The endpoint | ||
| -- names the device's push channel and moves to whoever enables | ||
| -- notifications on it last. Signing out deletes the subscriptions that | ||
| -- session enabled, and a push service reporting one gone deletes it. | ||
| CREATE TABLE push_subscriptions ( | ||
| endpoint TEXT PRIMARY KEY NOT NULL, | ||
| user TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, | ||
| -- The session that enabled it; not a foreign key, so an expired session | ||
| -- keeps notifying until its device signs out. | ||
| session TEXT NOT NULL, | ||
| p256dh TEXT NOT NULL, | ||
| auth TEXT NOT NULL, | ||
| createdAt INTEGER NOT NULL | ||
| ); | ||
| CREATE INDEX push_subscriptions_user ON push_subscriptions(user); | ||
| CREATE INDEX push_subscriptions_session ON push_subscriptions(session); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| // Print a new Web Push (VAPID) key pair: the public key for VAPID_PUBLIC_KEY | ||
| // in wrangler.jsonc, and the private key for the VAPID_PRIVATE_KEY secret. | ||
| // Replacing the pair stops every existing subscription; devices re-enable. | ||
| import { base64url } from "../src/push/webpush"; | ||
|
|
||
| const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair; | ||
| const publicKey = new Uint8Array((await crypto.subtle.exportKey("raw", pair.publicKey)) as ArrayBuffer); | ||
| const { d } = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as JsonWebKey; | ||
| console.log(`VAPID_PUBLIC_KEY=${base64url(publicKey)}\nVAPID_PRIVATE_KEY=${d}`); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| // The installed app, for signed-in pages: the service worker that shows | ||
| // decision notifications, the app icon's unread badge, and the | ||
| // notification switch. Served as /app.js; pages work without it. | ||
| const unread = Number(document.body.dataset.unread); | ||
| if ("setAppBadge" in navigator) | ||
| (unread > 0 ? navigator.setAppBadge(unread) : navigator.clearAppBadge()).catch(() => {}); | ||
|
|
||
| const supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; | ||
| const registration = supported ? navigator.serviceWorker.register("/sw.js") : null; | ||
|
|
||
| async function post(path, body) { | ||
| const response = await fetch(path, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify(body), | ||
| }); | ||
| const result = await response.json().catch(() => null); | ||
| if (!response.ok) throw new Error(result?.error || "Request failed."); | ||
| return result; | ||
| } | ||
|
|
||
| function key(base64url) { | ||
| const text = atob(base64url.replaceAll("-", "+").replaceAll("_", "/")); | ||
| return Uint8Array.from(text, (c) => c.charCodeAt(0)); | ||
| } | ||
|
|
||
| // The browser's subscription, dropping one made for an earlier key pair: it | ||
| // cannot receive pushes signed with the current key, and it blocks subscribing | ||
| // with the new one. | ||
| async function current(publicKey) { | ||
| const subscription = await (await registration).pushManager.getSubscription(); | ||
| const made = subscription?.options.applicationServerKey; | ||
| const want = key(publicKey); | ||
| if (!made || (made.byteLength === want.length && new Uint8Array(made).every((b, i) => b === want[i]))) | ||
| return subscription; | ||
| await post("/push/unsubscribe", { endpoint: subscription.endpoint }).catch(() => {}); | ||
| await subscription.unsubscribe(); | ||
| return null; | ||
| } | ||
|
|
||
| // Signing out stops this device's notifications at once and clears the app | ||
| // badge, which signed-out pages never update. The server forgets the session's | ||
| // subscriptions, and dropping the browser's own also discards any push the push | ||
| // service still holds for it. A sign-out without JavaScript leaves those to | ||
| // arrive within their TTL. | ||
| for (const form of document.querySelectorAll('form[action="/sign-out"]')) { | ||
| form.addEventListener( | ||
| "submit", | ||
| (event) => { | ||
| event.preventDefault(); | ||
| const steps = [ | ||
| "clearAppBadge" in navigator ? navigator.clearAppBadge() : null, | ||
| supported | ||
| ? registration | ||
| .then((found) => found.pushManager.getSubscription()) | ||
| .then((subscription) => subscription?.unsubscribe()) | ||
| : null, | ||
| ]; | ||
| Promise.allSettled(steps).then(() => form.submit()); | ||
| }, | ||
| { once: true }, | ||
| ); | ||
| } | ||
|
|
||
| // Whether the server notifies this browser's subscription for whoever is | ||
| // signed in. It forgets one when a session signs out, and never hands one to | ||
| // another user, so the browser's own subscription is not enough to show "on". | ||
| const controls = document.querySelectorAll("[data-push]"); | ||
| let stored = false; | ||
| const checked = (async () => { | ||
| if (!supported || !controls.length || Notification.permission !== "granted") return; | ||
| const subscription = await current(controls[0].dataset.push); | ||
| if (subscription) stored = (await post("/push/check", { endpoint: subscription.endpoint })).stored === true; | ||
| })(); | ||
|
|
||
| for (const control of controls) { | ||
| const status = control.querySelector("[data-push-status]"); | ||
| const enable = control.querySelector("[data-push-enable]"); | ||
| const disable = control.querySelector("[data-push-disable]"); | ||
|
|
||
| async function show(message) { | ||
| const subscription = supported && (await (await registration).pushManager.getSubscription()); | ||
| const on = stored && !!subscription && Notification.permission === "granted"; | ||
| const blocked = supported && Notification.permission === "denied"; | ||
| // The inbox's compact control steps aside once there is nothing to switch on. | ||
| control.hidden = (on || blocked) && "compact" in control.dataset && !message; | ||
| enable.hidden = !supported || on || blocked; | ||
| disable.hidden = !on; | ||
| status.textContent = | ||
| message ?? | ||
| (!supported | ||
| ? "This browser can't show notifications here. On an iPhone or iPad, add Quest to your Home Screen from the Share menu, then open it from there." | ||
| : blocked | ||
| ? "Notifications are blocked for this site in your browser's settings." | ||
| : on | ||
| ? "This device is notified when something needs your decision." | ||
| : "Get a notification on this device when something needs your decision."); | ||
| } | ||
|
|
||
| async function run(button, action) { | ||
| button.disabled = true; | ||
| try { | ||
| await show(await action()); | ||
| } catch (error) { | ||
| await show(error.message || "Something went wrong."); | ||
| } finally { | ||
| button.disabled = false; | ||
| } | ||
| } | ||
|
|
||
| // The permission prompt comes only from this click. | ||
| enable.addEventListener("click", () => | ||
| run(enable, async () => { | ||
| if ((await Notification.requestPermission()) !== "granted") return undefined; | ||
| await current(control.dataset.push); | ||
| const subscription = await ( | ||
| await registration | ||
| ).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key(control.dataset.push) }); | ||
| await post("/push/subscribe", subscription.toJSON()); | ||
| stored = true; | ||
| return "Notifications are on for this device."; | ||
| }), | ||
| ); | ||
| disable.addEventListener("click", () => | ||
| run(disable, async () => { | ||
| const subscription = await (await registration).pushManager.getSubscription(); | ||
| if (subscription) { | ||
| await post("/push/unsubscribe", { endpoint: subscription.endpoint }); | ||
| stored = false; | ||
| await subscription.unsubscribe(); | ||
| } | ||
| return "Notifications are off for this device."; | ||
| }), | ||
| ); | ||
| checked | ||
| .then( | ||
| () => show(), | ||
| (error) => show(error.message || "Something went wrong."), | ||
| ) | ||
| .catch(() => {}); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,9 @@ | ||
| // Optional Google sign-in secret, which `wrangler types` cannot declare without | ||
| // making every deploy require it. Wrangler dev loads only declared secrets, so | ||
| // to try Google locally, temporarily add it to `secrets.required`. | ||
| // Optional secrets, which `wrangler types` cannot declare without making every | ||
| // deploy require them. Wrangler dev loads only declared secrets, so to try one | ||
| // locally, temporarily add it to `secrets.required`. | ||
| interface Env { | ||
| // Google sign-in, offered only with GOOGLE_CLIENT_ID. | ||
| GOOGLE_CLIENT_SECRET?: string; | ||
| // The Web Push VAPID private key; notifications are offered only with VAPID_PUBLIC_KEY. | ||
| VAPID_PRIVATE_KEY?: string; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a user signs out,
/sign-outremoves the D1 subscription but does not unsubscribe the browser'sPushSubscription. After the user signs back in, this check treats that stale browser subscription as enabled, hides the compact control, and never posts it back to/push/subscribe; the UI therefore claims the device is notified while the server has no destination and sends it nothing. Reconcile the existing subscription with the server before reporting notifications as on.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 6f17641a. Signing out now also unsubscribes the browser's own subscription (the sign-out form waits for it), so the next person to sign in on that browser opts in for themselves. When the switch loads with notification permission granted, it stores any existing browser subscription again for the current session, so it never shows a device as notified that the server forgot (sign-out without JS, or a session that expired and signed back in).
(Written by Claude Opus 5.5)