Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.
Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.
ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.
Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.
Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.
Чеклист ручной проверки на iPhone и Android — в docs/plan.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
381 lines
15 KiB
JavaScript
381 lines
15 KiB
JavaScript
// PWA: service worker, подписка на пуши и установка приложения
|
||
// (ADR-011, ADR-023, ADR-046).
|
||
//
|
||
// Экраны спрашивают отсюда состояние и сюда же отдают действия; в
|
||
// pushManager, IndexedDB и сеть они не ходят — как и с чатом, это делает
|
||
// один модуль.
|
||
//
|
||
// Пуш — сигнал: он говорит, что для устройства что-то есть, а содержимое
|
||
// приезжает очередью при подключении (ADR-011). Поэтому здесь нет ни
|
||
// сообщений, ни ключей — только подписка и разрешение.
|
||
|
||
import * as api from "./api.js";
|
||
import { NetworkError } from "./api.js";
|
||
import * as db from "./db.js";
|
||
import { b64url, unb64url } from "./crypto.js";
|
||
import { deviceId } from "./sync.js";
|
||
|
||
const WORKER = "/sw.js";
|
||
|
||
// iOS: пуши работают только у приложения, установленного на экран «Домой»
|
||
// (ADR-011). Признак — docs/ui.md, «Баннер установки»: iPhone|iPad
|
||
// и navigator.standalone !== true.
|
||
const IOS = /iPhone|iPad/;
|
||
|
||
const state = {
|
||
// Регистрация service worker: одна на страницу, ждут её все.
|
||
registering: null,
|
||
// beforeinstallprompt приходит один раз и ждёт кнопки в настройках
|
||
// (docs/ui.md, «Настройки»).
|
||
prompt: null,
|
||
// Разрешение спрашивается один раз за всю историю устройства
|
||
// (docs/ui.md, «Уведомления»); флаг в памяти закрывает вкладку от
|
||
// повторного вопроса, флаг в meta — устройство.
|
||
asked: false,
|
||
// Вопрос идёт прямо сейчас: два сообщения подряд не должны дать
|
||
// два запроса разрешения.
|
||
asking: false,
|
||
};
|
||
|
||
// Приглашение установки ловится с первой секунды: браузер показывает его
|
||
// сам и только раз. Предотвращённое событие оживает кнопкой в настройках.
|
||
addEventListener("beforeinstallprompt", (event) => {
|
||
event.preventDefault();
|
||
state.prompt = event;
|
||
});
|
||
|
||
// --- service worker -----------------------------------------------------
|
||
|
||
// register ставит service worker. Отдаёт регистрацию или null: браузер
|
||
// без service worker — это просто клиент без кэша оболочки и пушей,
|
||
// а не сломанный клиент.
|
||
export function register() {
|
||
if (!("serviceWorker" in navigator)) {
|
||
return Promise.resolve(null);
|
||
}
|
||
if (state.registering === null) {
|
||
state.registering = navigator.serviceWorker.register(WORKER).catch(() => null);
|
||
}
|
||
return state.registering;
|
||
}
|
||
|
||
// ready — регистрация с работающим service worker: подписка ставится
|
||
// только на неё. navigator.serviceWorker.ready ждёт вечно, если
|
||
// регистрации нет, — поэтому сначала register.
|
||
async function ready() {
|
||
const registration = await register();
|
||
if (registration === null) {
|
||
return null;
|
||
}
|
||
try {
|
||
return await navigator.serviceWorker.ready;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// --- уведомления --------------------------------------------------------
|
||
|
||
// supported — есть ли в браузере то, из чего складывается пуш. iOS вне
|
||
// установленного приложения сюда не проходит: там нет ни Notification,
|
||
// ни PushManager.
|
||
function supported() {
|
||
return "serviceWorker" in navigator
|
||
&& "PushManager" in self
|
||
&& "Notification" in self;
|
||
}
|
||
|
||
// notifications — состояние раздела «уведомления» (docs/ui.md):
|
||
// "on" — «включены», "off" — «выключены», "denied" — «запрещены
|
||
// в браузере». В "denied" сходится всё, чего кнопкой не включить:
|
||
// отклонённое разрешение, браузер без уведомлений, сервер без
|
||
// VAPID-ключа (ADR-046).
|
||
export async function notifications(key) {
|
||
if (!supported() || !key || Notification.permission === "denied") {
|
||
return "denied";
|
||
}
|
||
if (await turnedOff()) {
|
||
return "off";
|
||
}
|
||
const subscription = await current();
|
||
if (subscription === null) {
|
||
return "off";
|
||
}
|
||
// Подписка под прежней парой VAPID-ключей не работает и не починится
|
||
// сама: push-сервис отвечает на неё 403, а это не 404 и не 410, и
|
||
// сервер её не снимет. Для человека это «выключены», а кнопка
|
||
// «включить» подпишет заново под текущим ключом (ADR-049).
|
||
return sameKey(subscription, key) ? "on" : "off";
|
||
}
|
||
|
||
// turnedOff — уведомления выключены кнопкой в настройках. Явный отказ
|
||
// сильнее любой оставшейся подписки: сама она больше не включается
|
||
// (ADR-049).
|
||
async function turnedOff() {
|
||
try {
|
||
return (await db.meta(["notificationsOff"])).notificationsOff === true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// enable — «включить». Разрешение спрашивается по нажатию, подписка
|
||
// ставится после granted (docs/ui.md, «Уведомления»). Отдаёт новое
|
||
// состояние.
|
||
export async function enable(key) {
|
||
if (!supported() || !key) {
|
||
return "denied";
|
||
}
|
||
let permission;
|
||
try {
|
||
permission = await Notification.requestPermission();
|
||
} catch {
|
||
return "denied";
|
||
}
|
||
if (permission !== "granted") {
|
||
return permission === "denied" ? "denied" : "off";
|
||
}
|
||
// Человек решил всё сам: отказа больше нет, и спрашивать после первого
|
||
// сообщения не о чем (ADR-049).
|
||
state.asked = true;
|
||
await db.putMeta({ notificationsAsked: true, notificationsOff: false }).catch(() => {});
|
||
return (await attach(key)) ? "on" : "denied";
|
||
}
|
||
|
||
// disable — «выключить». Подписка снимается у push-сервиса и у сервера:
|
||
// первое действует сразу, второе убирает мёртвую строку из базы.
|
||
//
|
||
// Отказ запоминается раньше всего остального: без него первое же
|
||
// отправленное сообщение вернуло бы подписку через askOnce, а запуск
|
||
// приложения — через refresh (ADR-049).
|
||
export async function disable() {
|
||
await db.putMeta({ notificationsOff: true }).catch(() => {});
|
||
const subscription = await current();
|
||
if (subscription !== null) {
|
||
try {
|
||
await subscription.unsubscribe();
|
||
} catch {
|
||
// Отписаться не дали: сервер уберёт подписку по 404/410 (ADR-011).
|
||
}
|
||
}
|
||
const device = deviceId();
|
||
if (device) {
|
||
await api.clearPush(device);
|
||
}
|
||
}
|
||
|
||
// askOnce — запрос разрешения после первого успешно отправленного
|
||
// сообщения за всю историю устройства, один раз (ADR-011, docs/ui.md,
|
||
// «Уведомления»). Отказ — молча: включить можно в настройках.
|
||
//
|
||
// На iOS вне установленного приложения вопроса нет вовсе: там вместо
|
||
// него баннер установки (ADR-023), а флаг не ставится — установят,
|
||
// спросим после следующего сообщения.
|
||
export async function askOnce(key) {
|
||
if (state.asked || state.asking) {
|
||
return;
|
||
}
|
||
state.asking = true;
|
||
try {
|
||
let meta;
|
||
try {
|
||
meta = await db.meta(["notificationsAsked", "notificationsOff"]);
|
||
} catch {
|
||
return;
|
||
}
|
||
// Выключенные в настройках уведомления сами не включаются: вопрос
|
||
// закрыт человеком, а не нами (ADR-049).
|
||
if (meta.notificationsOff === true) {
|
||
return;
|
||
}
|
||
if (meta.notificationsAsked === true) {
|
||
state.asked = true;
|
||
return;
|
||
}
|
||
if (iosBrowser() || !supported() || !key) {
|
||
return;
|
||
}
|
||
// Спрашивать нечего: разрешение уже дано или уже отклонено. Данное —
|
||
// повод поставить подписку, если её нет.
|
||
if (Notification.permission !== "default") {
|
||
await remember();
|
||
if (Notification.permission === "granted") {
|
||
await attach(key).catch(() => {});
|
||
}
|
||
return;
|
||
}
|
||
let permission;
|
||
try {
|
||
permission = await Notification.requestPermission();
|
||
} catch {
|
||
// Браузер требует нажатия, а между отправкой и ответом сервера оно
|
||
// истекло. Вопроса не было — значит, «один раз» ещё не потрачено:
|
||
// попробуем после следующего сообщения.
|
||
return;
|
||
}
|
||
await remember();
|
||
if (permission === "granted") {
|
||
await attach(key).catch(() => {});
|
||
}
|
||
} finally {
|
||
state.asking = false;
|
||
}
|
||
}
|
||
|
||
// remember — вопрос задан, второй раз не спрашиваем ни в этой вкладке,
|
||
// ни на этом устройстве (docs/ui.md, «Уведомления»).
|
||
async function remember() {
|
||
state.asked = true;
|
||
await db.putMeta({ notificationsAsked: true }).catch(() => {});
|
||
}
|
||
|
||
// refresh переставляет подписку на текущее устройство. Подписка живёт
|
||
// в браузерном профиле, а принадлежит устройству (ADR-023): deviceId
|
||
// меняется при конфликте идентификаторов и после чистки IndexedDB,
|
||
// и запуск приложения это чинит (ADR-046).
|
||
//
|
||
// Выключенные уведомления запуск не включает, а подписку под прежним
|
||
// ключом сервера не переставляет: она всё равно не работает, и место ей
|
||
// не в базе, а в кнопке «включить» (ADR-049).
|
||
export async function refresh(key) {
|
||
if (await turnedOff()) {
|
||
return;
|
||
}
|
||
const subscription = await current();
|
||
if (subscription === null) {
|
||
return;
|
||
}
|
||
if (key && !sameKey(subscription, key)) {
|
||
return;
|
||
}
|
||
try {
|
||
await put(subscription);
|
||
} catch {
|
||
// Не переставили — переставим при следующем запуске.
|
||
}
|
||
}
|
||
|
||
// detach снимает подписку у push-сервиса при выходе из аккаунта
|
||
// и при его удалении: подписка принадлежит устройству, а устройство —
|
||
// аккаунту (ADR-046). Сервер не спрашиваем: сессии к этому моменту
|
||
// уже нет, а мёртвую подписку он уберёт сам по 404/410.
|
||
export async function detach() {
|
||
const subscription = await current();
|
||
if (subscription === null) {
|
||
return;
|
||
}
|
||
try {
|
||
await subscription.unsubscribe();
|
||
} catch {
|
||
// Не отписались — пуши всё равно некуда доставлять: истории на
|
||
// устройстве больше нет.
|
||
}
|
||
}
|
||
|
||
// current — подписка этого браузера или null.
|
||
async function current() {
|
||
const registration = await ready();
|
||
if (registration === null || !registration.pushManager) {
|
||
return null;
|
||
}
|
||
try {
|
||
return await registration.pushManager.getSubscription();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// attach ставит подписку и отдаёт её серверу. Ключ сервера вплетён
|
||
// в подписку: сменился ключ — прежняя подписка не годится, push-сервис
|
||
// подпишет заново.
|
||
async function attach(key) {
|
||
const registration = await ready();
|
||
if (registration === null || !registration.pushManager) {
|
||
return false;
|
||
}
|
||
let subscription = await registration.pushManager.getSubscription();
|
||
if (subscription !== null && !sameKey(subscription, key)) {
|
||
try {
|
||
await subscription.unsubscribe();
|
||
} catch {
|
||
// Старая подписка останется у push-сервиса; сервер её не знает.
|
||
}
|
||
subscription = null;
|
||
}
|
||
if (subscription === null) {
|
||
subscription = await registration.pushManager.subscribe({
|
||
userVisibleOnly: true,
|
||
applicationServerKey: unb64url(key),
|
||
});
|
||
}
|
||
await put(subscription);
|
||
return true;
|
||
}
|
||
|
||
// put отдаёт подписку серверу. Без устройства запрос невозможен:
|
||
// подписка принадлежит устройству, а его заводит подключение
|
||
// (ADR-017). Это то же состояние, что и не дошедший запрос.
|
||
async function put(subscription) {
|
||
const device = deviceId();
|
||
if (!device) {
|
||
throw new NetworkError();
|
||
}
|
||
await api.setPush(device, subscription.toJSON());
|
||
}
|
||
|
||
// sameKey — та ли пара VAPID-ключей, под которую выдана подписка.
|
||
function sameKey(subscription, key) {
|
||
const applied = subscription.options?.applicationServerKey;
|
||
if (!applied) {
|
||
return false;
|
||
}
|
||
try {
|
||
return b64url(new Uint8Array(applied)) === key;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// --- установка ----------------------------------------------------------
|
||
|
||
// iosBrowser — iPhone или iPad вне установленного приложения. Ровно этот
|
||
// признак показывает баннер установки (docs/ui.md).
|
||
export function iosBrowser() {
|
||
return IOS.test(navigator.userAgent) && navigator.standalone !== true;
|
||
}
|
||
|
||
// installable — поймано ли приглашение установки.
|
||
export function installable() {
|
||
return state.prompt !== null;
|
||
}
|
||
|
||
// install показывает приглашение установки. Оно одноразовое: показали —
|
||
// кнопки больше нет.
|
||
export async function install() {
|
||
const prompt = state.prompt;
|
||
if (prompt === null) {
|
||
return;
|
||
}
|
||
state.prompt = null;
|
||
try {
|
||
await prompt.prompt();
|
||
await prompt.userChoice;
|
||
} catch {
|
||
// Приглашение протухло: браузер покажет своё, когда сочтёт нужным.
|
||
}
|
||
}
|
||
|
||
// bannerHidden — баннер установки уже закрывали (docs/ui.md).
|
||
export async function bannerHidden() {
|
||
try {
|
||
return (await db.meta(["installBannerDismissed"])).installBannerDismissed === true;
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// hideBanner — крестик: повтор не показывается.
|
||
export async function hideBanner() {
|
||
await db.putMeta({ installBannerDismissed: true }).catch(() => {});
|
||
}
|