From 6832df6177ad973ad0e1b4f0a49d7a6da06c6e04 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 9 Oct 2026 12:08:31 +0200 Subject: feat: notifications on Android while closed, with nothing to install The phone fetches what is new every fifteen minutes with a poll secret (POST /v1/push/poll) that reads notification lines and nothing else. When a UnifiedPush distributor is already installed, the hub also pushes at once, encrypted to the phone (RFC 8291); losing the distributor falls back to fetching. The hub now honours "disable all notifications" itself: create_notification creates nothing for that account, as it already did for a muted group, so neither switch lets anything reach a phone. The interface used to be the only reader of the account-wide switch. Push endpoints are member-supplied URLs: a send refuses non-public addresses, connects to the address it checked, and follows no redirect. Android build untested here (no SDK on this machine). Co-Authored-By: Claude Opus 5.5 --- .../meshbay-hub/src/meshbay_hub/static/push.js | 68 ++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/push.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/push.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/push.js b/packages/meshbay-hub/src/meshbay_hub/static/push.js new file mode 100644 index 0000000..a075aa4 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/push.js @@ -0,0 +1,68 @@ +/** + * Notifications on this phone: the page's half. + * + * Nothing to install: the shell fetches what is new on its own every quarter + * of an hour, and when the phone already has a UnifiedPush distributor it is + * used too, so they arrive at once. The page gives the hub whatever the shell + * ends up with, because the page holds the session. Whether a notification is + * wanted — every one turned off, or a group muted — is decided on the hub, + * which then creates nothing (docs/MESHBAY_DESIGN.md §11.3). + */ +import * as platform from './platform.js'; +import { hubFetch } from './hub-client.js'; + +// How long turning it on waits for a distributor before registering without +// one; when the endpoint comes later, the next sync hands it over. +const WAIT_MS = 10_000; +const POLL_MS = 500; + +/** + * Make the hub's row match what this phone has: at every start and sign-in, + * and after turning it on. A distributor may hand out a new endpoint, or go + * away, at any time, page running or not; this is where the hub hears it. + */ +export async function syncPush(user) { + if (!platform.push.available || !user) return null; + const s = await platform.push.status(); + if (!s.enabled) return s; + const mine = s.subscription && s.account === user.userId; + if (mine && s.registered === s.endpoint) return s; + const body = s.endpoint + ? { endpoint: s.endpoint, p256dh: s.p256dh, auth: s.auth } + : {}; + if (mine) body.id = s.subscription; + const r = await hubFetch('/v1/push/subscriptions', { + method: 'POST', token: user.token, body, + }); + return platform.push.remember(r.id, user.userId, r.poll_secret, r.now); +} + +/** Turn it on: a moment for a distributor to answer, then register either way. */ +export async function enablePush(user, onProgress) { + let s = await platform.push.enable(); + const until = Date.now() + WAIT_MS; + while (s.state === 'pending' && Date.now() < until) { + if (onProgress) onProgress(s); + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); + s = await platform.push.status(); + } + return syncPush(user).then((synced) => synced || s); +} + +/** + * Turn it off: the hub forgets this phone first, then the phone stops. The hub + * half is the one that can fail; the phone half happens regardless, so a + * refused request never leaves it on here. + */ +export async function disablePush(user) { + if (!platform.push.available) return null; + const s = await platform.push.status(); + if (s.subscription && user && s.account === user.userId) { + try { + await hubFetch(`/v1/push/subscriptions/${s.subscription}`, { + method: 'DELETE', token: user.token, + }); + } catch (e) { /* already gone, or unreachable: the phone half still happens */ } + } + return platform.push.disable(); +} -- cgit v1.2.3